From 71fe77ebc6f04a867e735a23569a98b25a6f95c5 Mon Sep 17 00:00:00 2001 From: Lovehsigure_520 <62863834+Neko-233@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:19:28 +0800 Subject: [PATCH] fix(auth): preserve mobile provider hints (#2281) --- server/apps/auth/src/routes.ts | 28 +++++++- .../auth/src/tests/routes-userinfo.test.ts | 67 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/server/apps/auth/src/routes.ts b/server/apps/auth/src/routes.ts index 79aea9e57..5772f38e2 100644 --- a/server/apps/auth/src/routes.ts +++ b/server/apps/auth/src/routes.ts @@ -31,6 +31,8 @@ export const DEFAULT_AUTH_UI_URL = 'https://accounts.airi.build/ui' export const SERVER_DEV_PUBLIC_URL = 'https://airi-server-dev.up.railway.app' export const SERVER_DEV_AUTH_UI_URL = 'https://server-dev.airi-server-auth.pages.dev/ui' +const FORWARDED_AUTH_UI_PROVIDERS = new Set(['google', 'github', 'steam']) + /** Builds a route URL below the configured standalone Auth UI base. */ export function buildAuthUiUrl(authUiUrl: string, path: string, search = ''): string { const target = new URL(authUiUrl) @@ -72,6 +74,30 @@ export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, ap return target.toString() } +/** Restores a trusted mobile provider hint after the OIDC plugin builds its sign-in redirect. */ +function forwardAuthUiProviderHint(requestUrl: string, response: Response): Response { + const request = new URL(requestUrl) + const provider = request.searchParams.get('provider') + const location = response.headers.get('location') + + if (response.status < 300 || response.status >= 400 || !provider || !FORWARDED_AUTH_UI_PROVIDERS.has(provider) || !location) + return response + + const target = new URL(location, request) + if (target.origin !== request.origin || target.pathname !== `${SERVER_AUTH_UI_BASE_PATH}/sign-in`) + return response + + target.searchParams.set('provider', provider) + + const headers = new Headers(response.headers) + headers.set('location', location.startsWith('/') ? `${target.pathname}${target.search}${target.hash}` : target.toString()) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + const remoteJwksByUrl = new Map>() function readBearerToken(headers: Headers): string | null { @@ -254,7 +280,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { if (!(response instanceof Response)) throw new TypeError('Expected auth handler to return a Response') - return response + return forwardAuthUiProviderHint(request.url, response) } return new Hono() diff --git a/server/apps/auth/src/tests/routes-userinfo.test.ts b/server/apps/auth/src/tests/routes-userinfo.test.ts index 4e31966bc..3516f5b5f 100644 --- a/server/apps/auth/src/tests/routes-userinfo.test.ts +++ b/server/apps/auth/src/tests/routes-userinfo.test.ts @@ -90,6 +90,73 @@ describe('oidc /oauth2/userinfo ban guard', () => { }) describe('auth UI routes', () => { + it('keeps the Google provider hint on the OIDC sign-in redirect', async () => { + const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + handler.mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' }, + })) + + const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=google') + + // ROOT CAUSE: + // + // The OIDC plugin signs only its known query fields before it redirects. + // It removed the mobile provider hint, so Google opened the generic sign-in page. + // + // Before: /auth/sign-in?client_id=airi-stage-pocket&response_type=code + // After: /auth/sign-in?client_id=airi-stage-pocket&response_type=code&provider=google + expect(res.headers.get('location')).toBe('/auth/sign-in?client_id=airi-stage-pocket&response_type=code&provider=google') + }) + + it('keeps the GitHub provider hint on the OIDC sign-in redirect', async () => { + const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + handler.mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' }, + })) + + const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=github') + + expect(res.headers.get('location')).toBe('/auth/sign-in?client_id=airi-stage-pocket&response_type=code&provider=github') + }) + + it('keeps the Steam provider hint on the OIDC sign-in redirect', async () => { + const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + handler.mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' }, + })) + + const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=steam') + + expect(res.headers.get('location')).toBe('/auth/sign-in?client_id=airi-stage-pocket&response_type=code&provider=steam') + }) + + it('does not forward an unknown provider to the auth UI', async () => { + const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + handler.mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' }, + })) + + const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code&provider=unknown') + + expect(res.headers.get('location')).toBe('/auth/sign-in?client_id=airi-stage-pocket&response_type=code') + }) + + it('keeps the generic sign-in redirect when the request has no provider', async () => { + const { routes, handler } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + handler.mockResolvedValueOnce(new Response(null, { + status: 302, + headers: { location: '/auth/sign-in?client_id=airi-stage-pocket&response_type=code' }, + })) + + const res = await routes.request('/api/auth/oauth2/authorize?client_id=airi-stage-pocket&response_type=code') + + expect(res.headers.get('location')).toBe('/auth/sign-in?client_id=airi-stage-pocket&response_type=code') + }) + it('redirects sign-in provider shortcut to the standalone auth UI', async () => { const { routes } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })