From 95b7fccca2061bc93f55ec9015cda3f59d603161 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Tue, 10 Jun 2025 05:56:14 +0400 Subject: [PATCH] uberf-9797: idp auth state (#9196) --- pods/authProviders/src/github.ts | 22 +++++++++++++++++++--- pods/authProviders/src/google.ts | 28 +++++++++++++++++++++------- pods/authProviders/src/openid.ts | 22 +++++++++++++++++++--- pods/authProviders/src/utils.ts | 29 +++++++++++++++++++++++++++-- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 69e9a6dbcc..5ba33ce01c 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -4,7 +4,13 @@ import { BrandingMap, concatLink, MeasureContext, getBranding, SocialIdType } fr import Router from 'koa-router' import { Strategy as GitHubStrategy } from 'passport-github2' import { Passport } from '.' -import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' +import { + encodeState, + handleProviderAuth, + safeParseAuthState, + setAuthStateTokenCookie, + validateAuthStateTokenCookie +} from './utils' export function registerGithub ( measureCtx: MeasureContext, @@ -39,7 +45,11 @@ export function registerGithub ( router.get('/auth/github', async (ctx, next) => { measureCtx.info('try auth via', { provider: 'github' }) - const state = encodeState(ctx, brandings) + + const nonce = crypto.randomUUID() + setAuthStateTokenCookie(ctx, nonce) + + const state = encodeState(ctx, brandings, nonce) passport.authenticate('github', { scope: ['user:email'], session: true, state })(ctx, next) }) @@ -49,9 +59,15 @@ export function registerGithub ( async (ctx, next) => { const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const loginUrl = concatLink(branding?.front ?? frontUrl, '/login') + const isValidState = validateAuthStateTokenCookie(ctx, state?.nonce) + if (!isValidState) { + ctx.redirect(loginUrl) + return + } await passport.authenticate('github', { - failureRedirect: concatLink(branding?.front ?? frontUrl, '/login'), + failureRedirect: loginUrl, session: true })(ctx, next) }, diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index c9a1975383..dbf93508ba 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -4,7 +4,13 @@ import { BrandingMap, concatLink, MeasureContext, getBranding, SocialIdType } fr import Router from 'koa-router' import { Strategy as GoogleStrategy } from 'passport-google-oauth20' import { Passport } from '.' -import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' +import { + encodeState, + handleProviderAuth, + safeParseAuthState, + setAuthStateTokenCookie, + validateAuthStateTokenCookie +} from './utils' export function registerGoogle ( measureCtx: MeasureContext, @@ -39,7 +45,11 @@ export function registerGoogle ( router.get('/auth/google', async (ctx, next) => { measureCtx.info('try auth via', { provider: 'google' }) - const state = encodeState(ctx, brandings) + + const nonce = crypto.randomUUID() + setAuthStateTokenCookie(ctx, nonce) + + const state = encodeState(ctx, brandings, nonce) passport.authenticate('google', { scope: ['profile', 'email'], session: true, state })(ctx, next) }) @@ -48,13 +58,17 @@ export function registerGoogle ( redirectURL, async (ctx, next) => { const state = safeParseAuthState(ctx.query?.state) - measureCtx.info('Auth state', { state }) const branding = getBranding(brandings, state?.branding) - measureCtx.info('With branding', { branding }) - const failureRedirect = concatLink(branding?.front ?? frontUrl, '/login') - measureCtx.info('With failure redirect', { failureRedirect }) + + const loginUrl = concatLink(branding?.front ?? frontUrl, '/login') + const isValidState = validateAuthStateTokenCookie(ctx, state?.nonce) + if (!isValidState) { + ctx.redirect(loginUrl) + return + } + await passport.authenticate('google', { - failureRedirect, + failureRedirect: loginUrl, session: true })(ctx, next) }, diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts index 8aa79106f5..271e221667 100644 --- a/pods/authProviders/src/openid.ts +++ b/pods/authProviders/src/openid.ts @@ -19,7 +19,13 @@ import Router from 'koa-router' import { Issuer, Strategy } from 'openid-client' import { Passport } from '.' -import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' +import { + encodeState, + handleProviderAuth, + safeParseAuthState, + setAuthStateTokenCookie, + validateAuthStateTokenCookie +} from './utils' export function registerOpenid ( measureCtx: MeasureContext, @@ -66,7 +72,11 @@ export function registerOpenid ( router.get('/auth/openid', async (ctx, next) => { measureCtx.info('try auth via', { provider: 'openid' }) - const state = encodeState(ctx, brandings) + + const nonce = crypto.randomUUID() + setAuthStateTokenCookie(ctx, nonce) + + const state = encodeState(ctx, brandings, nonce) await passport.authenticate('oidc', { scope: 'openid profile email', @@ -79,9 +89,15 @@ export function registerOpenid ( async (ctx, next) => { const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const loginUrl = concatLink(branding?.front ?? frontUrl, '/login') + const isValidState = validateAuthStateTokenCookie(ctx, state?.nonce) + if (!isValidState) { + ctx.redirect(loginUrl) + return + } await passport.authenticate('oidc', { - failureRedirect: concatLink(branding?.front ?? frontUrl, '/login') + failureRedirect: loginUrl })(ctx, next) }, async (ctx, next) => { diff --git a/pods/authProviders/src/utils.ts b/pods/authProviders/src/utils.ts index e20d6bf413..7e8362e24b 100644 --- a/pods/authProviders/src/utils.ts +++ b/pods/authProviders/src/utils.ts @@ -32,6 +32,7 @@ export interface AuthState { branding?: string autoJoin?: boolean navigateUrl?: string + nonce?: string } export function safeParseAuthState (rawState: string | undefined): AuthState { @@ -46,14 +47,15 @@ export function safeParseAuthState (rawState: string | undefined): AuthState { } } -export function encodeState (ctx: any, brandings: BrandingMap): string { +export function encodeState (ctx: any, brandings: BrandingMap, nonce: string): string { const host = getHost(ctx.request.headers) const branding = host !== undefined ? brandings[host]?.key ?? undefined : undefined const state: AuthState = { inviteId: ctx.query?.inviteId, branding, autoJoin: ctx.query?.autoJoin !== undefined, - navigateUrl: ctx.query?.navigateUrl + navigateUrl: ctx.query?.navigateUrl, + nonce } return encodeURIComponent(JSON.stringify(state)) @@ -131,3 +133,26 @@ export async function handleProviderAuth ( return '' } } + +const authStateTokenCookie = 'auth_state_token' + +export function setAuthStateTokenCookie (ctx: any, nonce: string): void { + ctx.cookies.set(authStateTokenCookie, nonce, { + httpOnly: true, // Prevents JavaScript access to cookie + secure: true, // Only sent over HTTPS connections + sameSite: 'lax', // Prevents CSRF by controlling when cookie is sent cross-site + signed: true, // Signs cookie with app.keys to prevent tampering + maxAge: 10 * 60 * 1000 // 10 minutes + }) +} + +export function validateAuthStateTokenCookie (ctx: any, expectedNonce: string | undefined): boolean { + const nonce = ctx.cookies.get(authStateTokenCookie) + ctx.cookies.set(authStateTokenCookie, null) + + if (nonce === undefined || expectedNonce === undefined || nonce !== expectedNonce) { + return false + } + + return true +}