fix(auth): preserve mobile provider hints (#2281)

This commit is contained in:
Lovehsigure_520
2026-08-14 17:19:28 +08:00
committed by GitHub
parent 96465c8130
commit 71fe77ebc6
2 changed files with 94 additions and 1 deletions
+27 -1
View File
@@ -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<string, ReturnType<typeof createRemoteJWKSet>>()
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<HonoEnv>()
@@ -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 })