From be62dbbd1d0e0dfb134aed4b7ecc5cfa9cc4ff6c Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 12 May 2026 16:39:08 +0300 Subject: [PATCH 1/2] remove toWebRequest and make the package accept nitro event --- apps/blog-nuxt/server/lib/request.ts | 15 - .../server/routes/auth/github.get.ts | 4 +- .../server/routes/auth/github/callback.get.ts | 4 +- .../server/routes/auth/google.get.ts | 4 +- .../server/routes/auth/google/callback.get.ts | 4 +- apps/docs/docs/auth/social-login.md | 276 ++++++++++++++++-- packages/auth-social/src/index.ts | 168 ++++++++++- packages/auth-social/tests/package.test.ts | 71 ++++- 8 files changed, 482 insertions(+), 64 deletions(-) delete mode 100644 apps/blog-nuxt/server/lib/request.ts diff --git a/apps/blog-nuxt/server/lib/request.ts b/apps/blog-nuxt/server/lib/request.ts deleted file mode 100644 index 889bd9a..0000000 --- a/apps/blog-nuxt/server/lib/request.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getHeaders, getRequestURL, type H3Event } from 'h3' - -export function toWebRequest(event: H3Event): Request { - const headers = new Headers() - for (const [key, value] of Object.entries(getHeaders(event))) { - if (typeof value === 'string') { - headers.set(key, value) - } - } - - return new Request(getRequestURL(event), { - method: event.method, - headers, - }) -} diff --git a/apps/blog-nuxt/server/routes/auth/github.get.ts b/apps/blog-nuxt/server/routes/auth/github.get.ts index a760090..5281ff7 100644 --- a/apps/blog-nuxt/server/routes/auth/github.get.ts +++ b/apps/blog-nuxt/server/routes/auth/github.get.ts @@ -1,7 +1,5 @@ import { redirect } from '@holo-js/auth-social' -import { toWebRequest } from '../../lib/request' - export default defineEventHandler((event) => { - return redirect('github', toWebRequest(event)) + return redirect('github', event) }) diff --git a/apps/blog-nuxt/server/routes/auth/github/callback.get.ts b/apps/blog-nuxt/server/routes/auth/github/callback.get.ts index f65c483..cb65427 100644 --- a/apps/blog-nuxt/server/routes/auth/github/callback.get.ts +++ b/apps/blog-nuxt/server/routes/auth/github/callback.get.ts @@ -1,10 +1,8 @@ import auth from '@holo-js/auth' import { callback } from '@holo-js/auth-social' -import { toWebRequest } from '../../../lib/request' - export default defineEventHandler(async (event) => { - const result = await callback('github', toWebRequest(event)) + const result = await callback('github', event) if (!result.ok) { setResponseStatus(event, result.status) return { message: result.message } diff --git a/apps/blog-nuxt/server/routes/auth/google.get.ts b/apps/blog-nuxt/server/routes/auth/google.get.ts index 8655171..d2416cf 100644 --- a/apps/blog-nuxt/server/routes/auth/google.get.ts +++ b/apps/blog-nuxt/server/routes/auth/google.get.ts @@ -1,7 +1,5 @@ import { redirect } from '@holo-js/auth-social' -import { toWebRequest } from '../../lib/request' - export default defineEventHandler((event) => { - return redirect('google', toWebRequest(event)) + return redirect('google', event) }) diff --git a/apps/blog-nuxt/server/routes/auth/google/callback.get.ts b/apps/blog-nuxt/server/routes/auth/google/callback.get.ts index 6f91a70..3dab956 100644 --- a/apps/blog-nuxt/server/routes/auth/google/callback.get.ts +++ b/apps/blog-nuxt/server/routes/auth/google/callback.get.ts @@ -1,10 +1,8 @@ import auth from '@holo-js/auth' import { callback } from '@holo-js/auth-social' -import { toWebRequest } from '../../../lib/request' - export default defineEventHandler(async (event) => { - const result = await callback('google', toWebRequest(event)) + const result = await callback('google', event) if (!result.ok) { setResponseStatus(event, result.status) return { message: result.message } diff --git a/apps/docs/docs/auth/social-login.md b/apps/docs/docs/auth/social-login.md index 8179faf..594285e 100644 --- a/apps/docs/docs/auth/social-login.md +++ b/apps/docs/docs/auth/social-login.md @@ -25,11 +25,34 @@ Social login uses one shared runtime package plus one package per provider. Inst actually uses. ```bash +npx holo install auth --social npx holo install auth --social --provider google npx holo install auth --social --provider github npx holo install auth --social --provider google,github ``` +If you pass `--social` without `--provider`, Holo installs Google by default. In other words, +`npx holo install auth --social` is equivalent to `npx holo install auth --social --provider google`. + +Each social provider has its own package. Holo only installs the provider packages you specify: + +- Google uses `@holo-js/auth-social-google` +- GitHub uses `@holo-js/auth-social-github` +- Discord uses `@holo-js/auth-social-discord` +- Facebook uses `@holo-js/auth-social-facebook` +- Apple uses `@holo-js/auth-social-apple` +- LinkedIn uses `@holo-js/auth-social-linkedin` + +Providers that are not listed are not installed, are not added to `config/auth.ts`, and do not get env keys. You can +add another provider later by running the install command again: + +```bash +npx holo install auth --social --provider github +``` + +That adds GitHub support on top of the existing auth setup. It does not remove already configured providers such as +Google. + Supported first-party providers: - Google @@ -152,26 +175,17 @@ social: { That makes the social login resolve into the local model behind the `admin` guard instead of the default `web` guard. -## Redirecting Users - -Your route calls the social runtime: - -```ts -import { redirect } from '@holo-js/auth-social' - -export async function GET(request: Request) { - return redirect('google', request) -} -``` +## Route Shape -The provider name in `redirect('google', request)` must match the provider key in `config/auth.ts`. +Social login needs two app-owned routes per provider: -Typical route shapes: +| Purpose | Google Example | GitHub Example | +| --- | --- | --- | +| Start the OAuth redirect | `GET /auth/google` | `GET /auth/github` | +| Handle the provider callback | `GET /auth/google/callback` | `GET /auth/github/callback` | -- `GET /auth/google` -- `GET /auth/google/callback` -- `GET /auth/github` -- `GET /auth/github/callback` +The provider name in `redirect('google', input)` and `callback('google', input)` must match the provider key in +`config/auth.ts`. For a local app running on `http://localhost:3000`, put this in the provider dashboard: @@ -187,7 +201,147 @@ AUTH_GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback AUTH_GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback ``` -## Handling The Callback +## Redirect Helper + +Use `redirect(provider, input)` from `@holo-js/auth-social` in the route the user clicks. + +```ts +import { redirect } from '@holo-js/auth-social' + +const response = await redirect('google', requestOrEvent) +``` + +It returns: + +```ts +Promise +``` + +The returned response is a `302` redirect to the upstream provider authorization URL. Holo also stores the pending +OAuth state and PKCE verifier so the callback can be validated later. + +At runtime it looks like: + +```ts +Response { + status: 302, + headers: { + location: 'https://accounts.google.com/o/oauth2/v2/auth?...&state=...&code_challenge=...', + }, +} +``` + +The helper accepts: + +- a standard `Request` +- a Nuxt/H3 event +- an event-like object that exposes `request`, `web.request`, `req`, `node.req`, or `url`/`method`/`headers` + +Use the native request object for your framework. Do not build a separate request adapter in app code. + +## Callback Helper + +Use `callback(provider, input)` from `@holo-js/auth-social` in the provider callback route. + +```ts +import { callback } from '@holo-js/auth-social' + +const result = await callback('google', requestOrEvent) +``` + +It returns: + +```ts +type SocialCallbackResult = + | { + readonly ok: true + readonly guard: string + readonly authProvider: string + readonly provider: string + readonly user: AuthUserLike + } + | { + readonly ok: false + readonly status: 400 + readonly message: string + } +``` + +The success result does not redirect and does not create a session by itself. It gives your route the resolved local +user and selected guard so your route can use the framework's native redirect API after signing the user in. + +The user has the local auth provider's serialized shape: + +```ts +type AuthUserLike = { + readonly id?: string | number + readonly email?: string + readonly name?: string + readonly [key: string]: unknown +} +``` + +For a normal `users` provider, the object usually includes `id`, `email`, `name`, and any fields your provider +serializer exposes, such as `avatar` or `email_verified_at`. The external provider profile is linked in +`auth_identities`; app code should continue treating the local Holo user as canonical. + +At runtime a successful callback result looks like: + +```ts +{ + ok: true, + guard: 'web', + authProvider: 'users', + provider: 'google', + user: { + id: 1, + email: 'ada@example.com', + name: 'Ada Lovelace', + avatar: 'https://provider.example/avatar.png', + email_verified_at: new Date(), + }, +} +``` + +An invalid callback result looks like: + +```ts +{ + ok: false, + status: 400, + message: 'Invalid or expired OAuth state.', +} +``` + +The callback helper: + +- reads the upstream `code` and `state` from the callback request +- validates the saved state +- validates PKCE data when the provider flow uses it +- exchanges the authorization code with the provider package +- normalizes the provider profile +- resolves or creates a local user +- links the social identity +- returns the selected guard, local auth provider, provider key, and local user + +## Framework Examples + +The examples below use Google. Replace `google` with another configured provider key when creating routes for GitHub, +Discord, Facebook, Apple, or LinkedIn. + +### Next.js + +Create the redirect route at `app/auth/google/route.ts`: + +```ts +import { redirect } from '@holo-js/auth-social' + +export function GET(request: Request): Promise { + return redirect('google', request) +} +``` + +Create the callback route at `app/auth/google/callback/route.ts`: ```ts import { redirect } from 'next/navigation' @@ -209,23 +363,81 @@ export async function GET(request: Request) { } ``` -The callback route should receive the upstream `code` and `state` values, then pass the full request through to Holo. -Holo validates the state, verifies PKCE when that provider flow uses it, exchanges the authorization code, links the -identity, and returns the local user. +### Nuxt -Use `loginUsing()` when the selected guard is session-based, then redirect with your framework's native redirect API. -Token guard flows can create a token from the returned user instead of creating a session. +Nuxt server helpers such as `defineEventHandler`, `setResponseStatus`, and `sendRedirect` are available in Nuxt server +routes. Import them from `h3` if your project does not use Nuxt auto-imports. -The callback flow: +Create the redirect route at `server/routes/auth/google.get.ts`: -- validates the saved state -- validates PKCE data -- exchanges the authorization code -- loads the provider profile -- resolves or creates a local user -- links the social identity -- establishes a local session when using a session guard with `loginUsing()`, or returns an authenticated user that - token guards can use to create an access token +```ts +import { redirect } from '@holo-js/auth-social' + +export default defineEventHandler((event) => { + return redirect('google', event) +}) +``` + +Create the callback route at `server/routes/auth/google/callback.get.ts`: + +```ts +import auth from '@holo-js/auth' +import { callback } from '@holo-js/auth-social' + +export default defineEventHandler(async (event) => { + const result = await callback('google', event) + if (!result.ok) { + setResponseStatus(event, result.status) + return { + message: result.message, + } + } + + await auth.guard(result.guard).loginUsing(result.user) + return sendRedirect(event, '/admin', 303) +}) +``` + +Nuxt routes should pass the H3 event directly. Holo reads the method, URL, and headers from the event-like input. + +### SvelteKit + +Create the redirect route at `src/routes/auth/google/+server.ts`: + +```ts +import { redirect } from '@holo-js/auth-social' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = ({ request }) => { + return redirect('google', request) +} +``` + +Create the callback route at `src/routes/auth/google/callback/+server.ts`: + +```ts +import { json, redirect } from '@sveltejs/kit' +import auth from '@holo-js/auth' +import { callback } from '@holo-js/auth-social' +import type { RequestHandler } from './$types' + +export const GET: RequestHandler = async ({ request }) => { + const result = await callback('google', request) + if (!result.ok) { + return json({ + message: result.message, + }, { + status: result.status, + }) + } + + await auth.guard(result.guard).loginUsing(result.user) + throw redirect(303, '/admin') +} +``` + +Use `loginUsing()` when the selected guard is session-based, then redirect with your framework's native redirect API. +Token guard flows can create a token from the returned user instead of creating a session. Each provider package handles its own upstream field mapping. Holo does not guess raw provider response shapes across different services. diff --git a/packages/auth-social/src/index.ts b/packages/auth-social/src/index.ts index 984c38c..3aebbd3 100644 --- a/packages/auth-social/src/index.ts +++ b/packages/auth-social/src/index.ts @@ -43,6 +43,41 @@ export interface SocialProviderRuntime { }> } +export type SocialRequestHeaders = + | Headers + | ReadonlyArray + | Record + | { + readonly get?: (name: string) => string | null | undefined + readonly forEach?: (callback: (value: string, key: string) => void) => void + readonly entries?: () => Iterable + } + +export type SocialRequestLike = { + readonly method?: string + readonly path?: string + readonly url?: string | URL + readonly headers?: SocialRequestHeaders + readonly request?: Request + readonly req?: Request | { + readonly method?: string + readonly url?: string + readonly headers?: SocialRequestHeaders + } + readonly node?: { + readonly req?: { + readonly method?: string + readonly url?: string + readonly headers?: SocialRequestHeaders + } + } + readonly web?: { + readonly request?: Request + } +} + +export type SocialRequestInput = Request | SocialRequestLike + export interface SocialPendingStateRecord { readonly provider: string readonly state: string @@ -84,8 +119,8 @@ export interface SocialAuthBindings { } export interface SocialAuthFacade { - redirect(provider: string, request: Request): Promise - callback(provider: string, request: Request): Promise + redirect(provider: string, request: SocialRequestInput): Promise + callback(provider: string, request: SocialRequestInput): Promise } export type SocialCallbackResult = SocialCallbackSuccess | SocialCallbackFailure @@ -115,6 +150,129 @@ function getSocialRuntimeGlobal(): SocialRuntimeGlobal { return globalThis as SocialRuntimeGlobal } +function isPlainHeaderRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype +} + +function appendKnownHeaders(headers: Headers, input: { readonly get?: (name: string) => string | null | undefined }): void { + for (const name of ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto']) { + const value = input.get?.(name) + if (typeof value === 'string' && value) { + headers.set(name, value) + } + } +} + +function hasHeaderForEach(input: SocialRequestHeaders): input is { readonly forEach: (callback: (value: string, key: string) => void) => void } { + return !Array.isArray(input) && 'forEach' in input && typeof input.forEach === 'function' +} + +function hasHeaderEntries(input: SocialRequestHeaders): input is { readonly entries: () => Iterable } { + return !Array.isArray(input) && 'entries' in input && typeof input.entries === 'function' +} + +function hasHeaderGet(input: SocialRequestHeaders): input is { readonly get: (name: string) => string | null | undefined } { + return !Array.isArray(input) && 'get' in input && typeof input.get === 'function' +} + +function normalizeRequestHeaders(input: SocialRequestHeaders | undefined): Headers { + const headers = new Headers() + if (!input) { + return headers + } + + if (input instanceof Headers || Array.isArray(input)) { + new Headers(input).forEach((value, name) => headers.append(name, value)) + return headers + } + + if (hasHeaderForEach(input)) { + input.forEach((value, name) => headers.append(name, value)) + return headers + } + + if (hasHeaderEntries(input)) { + for (const [name, value] of input.entries()) { + headers.append(name, value) + } + return headers + } + + if (hasHeaderGet(input)) { + appendKnownHeaders(headers, input) + return headers + } + + if (isPlainHeaderRecord(input)) { + for (const [name, value] of Object.entries(input)) { + if (typeof value === 'string') { + headers.append(name, value) + continue + } + + if (Array.isArray(value)) { + const separator = name.toLowerCase() === 'cookie' ? '; ' : ',' + const joined = value.filter((entry): entry is string => typeof entry === 'string').join(separator) + if (joined) { + headers.append(name, joined) + } + } + } + } + + return headers +} + +function getRequestFromLikeInput(input: SocialRequestLike): Request | undefined { + return input.request ?? input.web?.request ?? (input.req instanceof Request ? input.req : undefined) +} + +function getRequestLikeHeaders(input: SocialRequestLike): SocialRequestHeaders | undefined { + return input.headers + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.headers : undefined) + ?? input.node?.req?.headers +} + +function getRequestLikeMethod(input: SocialRequestLike): string { + return input.method + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.method : undefined) + ?? input.node?.req?.method + ?? 'GET' +} + +function getRequestLikeUrl(input: SocialRequestLike, headers: Headers): string { + const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) + ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) + ?? input.node?.req?.url + ?? input.path + ?? '/' + + try { + return new URL(url).toString() + } catch { + const protocol = headers.get('x-forwarded-proto') ?? 'http' + const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost' + return new URL(url, `${protocol}://${host}`).toString() + } +} + +function normalizeSocialRequest(input: SocialRequestInput): Request { + if (input instanceof Request) { + return input + } + + const request = getRequestFromLikeInput(input) + if (request) { + return request + } + + const headers = normalizeRequestHeaders(getRequestLikeHeaders(input)) + return new Request(getRequestLikeUrl(input, headers), { + method: getRequestLikeMethod(input), + headers, + }) +} + function requireUserRecord(user: unknown, message: string): Record { if (user == null) { throw new Error(message) @@ -433,7 +591,8 @@ async function resolveLinkedUser( } } -export async function redirect(provider: string, request: Request): Promise { +export async function redirect(provider: string, input: SocialRequestInput): Promise { + const request = normalizeSocialRequest(input) const providerConfig = getConfiguredProviderConfig(provider) const runtime = getProviderRuntime(provider) const { guard } = resolveGuardAndProvider(provider) @@ -503,7 +662,8 @@ async function readCallbackParameters(request: Request): Promise<{ } } -export async function callback(provider: string, request: Request): Promise { +export async function callback(provider: string, input: SocialRequestInput): Promise { + const request = normalizeSocialRequest(input) const { state, code } = await readCallbackParameters(request) if (!state || !code) { return { diff --git a/packages/auth-social/tests/package.test.ts b/packages/auth-social/tests/package.test.ts index de95d94..452c619 100644 --- a/packages/auth-social/tests/package.test.ts +++ b/packages/auth-social/tests/package.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' import { configureSessionRuntime, getSessionRuntime, resetSessionRuntime } from '../../session/src/runtime' import auth, { authRuntimeInternals, configureAuthRuntime, defineAuthConfig, resetAuthRuntime, tokens } from '../../auth/src' import type { AuthProviderAdapter, AuthTokenStore, PersonalAccessTokenRecord } from '../../auth/src' @@ -9,6 +9,7 @@ import { redirect, resetSocialAuthRuntime, socialAuth, + type SocialAuthFacade, socialAuthInternals, } from '../src' @@ -300,6 +301,74 @@ describe('@holo-js/auth-social', () => { expect(socialAuth.callback).toBe(callback) }) + it('accepts Nuxt-style event input for redirects and callbacks', async () => { + type NuxtEventLike = { + readonly method: string + readonly node: { + readonly req: { + readonly url: string + readonly headers: Record + } + } + } + + expectTypeOf().toMatchTypeOf[1]>() + expectTypeOf().toMatchTypeOf[1]>() + + const runtime = configureRuntime() + const redirectResponse = await redirect('google', { + method: 'GET', + node: { + req: { + url: '/auth/google', + headers: { + host: 'app.test', + 'x-forwarded-proto': 'https', + }, + }, + }, + }) + const location = redirectResponse.headers.get('location') + expect(location).toContain('https://accounts.example.com/oauth/authorize') + const state = new URL(location!).searchParams.get('state') + expect(state).toBeTruthy() + + const pending = await runtime.stateStore.read('google', state!) + expect(pending).toBeTruthy() + runtime.exchangeProfiles.set('event-code', { + expectedVerifier: pending!.codeVerifier, + profile: { + id: 'google-event', + email: 'event@example.com', + emailVerified: true, + name: 'Event User', + }, + tokens: { accessToken: 'event-token' }, + }) + + const result = await callback('google', { + method: 'GET', + node: { + req: { + url: `/auth/google/callback?state=${encodeURIComponent(state!)}&code=event-code`, + headers: { + host: 'app.test', + 'x-forwarded-proto': 'https', + }, + }, + }, + }) + expect(result).toMatchObject({ + ok: true, + guard: 'web', + provider: 'google', + user: { + email: 'event@example.com', + name: 'Event User', + }, + }) + }) + it('builds a redirect URL with state and PKCE and rejects unknown callback state', async () => { const runtime = configureRuntime() const response = await redirect('google', new Request('https://app.test/auth/google')) From eb4cf5e65c8d2e74c084b08913f8abc829b4e68d Mon Sep 17 00:00:00 2001 From: Mohamed Melouk <42706279+cobraprojects@users.noreply.github.com> Date: Tue, 12 May 2026 16:51:21 +0300 Subject: [PATCH 2/2] fix --- packages/auth-social/src/index.ts | 25 +++++++-- packages/auth-social/tests/package.test.ts | 62 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/auth-social/src/index.ts b/packages/auth-social/src/index.ts index 3aebbd3..359187b 100644 --- a/packages/auth-social/src/index.ts +++ b/packages/auth-social/src/index.ts @@ -141,6 +141,7 @@ export interface SocialCallbackFailure { const SOCIAL_BINDINGS_KEY = '__holoAuthSocialBindings__' const AUTH_PROVIDER_MARKER = Symbol.for('holo-js.auth.provider') +const GET_ONLY_REQUEST_HEADER_NAMES = ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto'] as const type RuntimeAuthProviderAdapter = ReturnType['providers'][string] type SocialRuntimeGlobal = typeof globalThis & { [SOCIAL_BINDINGS_KEY]?: SocialAuthBindings @@ -154,8 +155,10 @@ function isPlainHeaderRecord(value: unknown): value is Record string | null | undefined }): void { - for (const name of ['authorization', 'cookie', 'host', 'x-forwarded-host', 'x-forwarded-proto']) { + for (const name of GET_ONLY_REQUEST_HEADER_NAMES) { const value = input.get?.(name) if (typeof value === 'string' && value) { headers.set(name, value) @@ -240,6 +243,22 @@ function getRequestLikeMethod(input: SocialRequestLike): string { ?? 'GET' } +function isProductionRuntime(): boolean { + return process.env.NODE_ENV === 'production' +} + +function createRelativeRequestBaseUrl(headers: Headers): string { + const forwardedProtocol = headers.get('x-forwarded-proto') + const forwardedHost = headers.get('x-forwarded-host') + if (isProductionRuntime() && (!forwardedProtocol || !forwardedHost)) { + throw new Error('[@holo-js/auth-social] Relative request URLs require x-forwarded-proto and x-forwarded-host headers in production.') + } + + const protocol = forwardedProtocol ?? 'http' + const host = forwardedHost ?? headers.get('host') ?? 'localhost' + return `${protocol}://${host}` +} + function getRequestLikeUrl(input: SocialRequestLike, headers: Headers): string { const url = (typeof input.url === 'string' ? input.url : input.url?.toString()) ?? (typeof input.req === 'object' && !(input.req instanceof Request) ? input.req.url : undefined) @@ -250,9 +269,7 @@ function getRequestLikeUrl(input: SocialRequestLike, headers: Headers): string { try { return new URL(url).toString() } catch { - const protocol = headers.get('x-forwarded-proto') ?? 'http' - const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost' - return new URL(url, `${protocol}://${host}`).toString() + return new URL(url, createRelativeRequestBaseUrl(headers)).toString() } } diff --git a/packages/auth-social/tests/package.test.ts b/packages/auth-social/tests/package.test.ts index 452c619..976a72f 100644 --- a/packages/auth-social/tests/package.test.ts +++ b/packages/auth-social/tests/package.test.ts @@ -369,6 +369,68 @@ describe('@holo-js/auth-social', () => { }) }) + it('allows relative event URLs in development but requires forwarded headers in production', async () => { + configureRuntime() + const previousNodeEnv = process.env.NODE_ENV + + try { + delete process.env.NODE_ENV + const developmentResponse = await redirect('google', { + method: 'GET', + node: { + req: { + url: '/auth/google', + headers: {}, + }, + }, + }) + expect(developmentResponse.status).toBe(302) + + process.env.NODE_ENV = 'production' + await expect(redirect('google', { + method: 'GET', + node: { + req: { + url: '/auth/google', + headers: {}, + }, + }, + })).rejects.toThrow('Relative request URLs require x-forwarded-proto and x-forwarded-host headers in production') + + await expect(redirect('google', { + method: 'GET', + node: { + req: { + url: '/auth/google', + headers: { + 'x-forwarded-proto': 'https', + }, + }, + }, + })).rejects.toThrow('Relative request URLs require x-forwarded-proto and x-forwarded-host headers in production') + + const productionResponse = await redirect('google', { + method: 'GET', + node: { + req: { + url: '/auth/google', + headers: { + 'x-forwarded-host': 'app.test', + 'x-forwarded-proto': 'https', + }, + }, + }, + }) + expect(productionResponse.status).toBe(302) + } finally { + if (typeof previousNodeEnv === 'string') { + process.env.NODE_ENV = previousNodeEnv + } else { + delete process.env.NODE_ENV + } + } + }) + it('builds a redirect URL with state and PKCE and rejects unknown callback state', async () => { const runtime = configureRuntime() const response = await redirect('google', new Request('https://app.test/auth/google'))