Skip to content

Commit 1870da7

Browse files
authored
auth-providers: OIDC callback hardening (#10931)
* fix(authProviders): instrument OIDC callback + redirect on fail (not 500) Production-blocking: every OIDC callback (bogus or real code) returned 500 Internal Server Error, even with valid sessions. No log was emitted between `try auth via openid` and the 500 — passport strategy threw silently before `handleProviderAuth` could log. Per Codex plan-review amendments: - Switch to explicit-callback passport.authenticate variant so strategy errors surface as err/info/status args instead of being swallowed. - Privacy-safe diagnostic shape (no raw code/state/tokens/user). Fields: stage, errMessage/Name/Stack, hasUser, infoSummary, statusCode, hasSession + sessionKeys (names only), cookie + state + code PRESENCE (lengths only), host, forwardedProto. - Permanent invalid-callback guard: ALL failures → 302 to /login with ?error=oidc_callback_failed, never 500. Same wrap covers handleProviderAuth throws. Image-tag: hardcoreeng/account:integration-wac-2026-06-21-codex-r7. Next step (P0-T2): user attempts login, we capture the actual error from the diagnostic, then ship a surgical fix in r8. Signed-off-by: Michael Uray <michael.uray@gmail.com> Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * fix(account): admin.ts trim+lowercase on both env-set AND lookup Codex plan-review Critical: server/account/src/admin.ts:15-18 built the ADMIN_EMAILS set without normalizing, AND isAdminEmail() didn't normalize the input. Today value is clean so isAdmin works, but a future env-value like ADMIN_EMAILS=" Michael@Uray.io " would silently break admin detection (Set lookup would miss the lowercased input). Mongo backend already has this hardening (collections/mongo.ts:1141-1147). Postgres + this helper didn't. Now BOTH sides normalize: env split -> trim -> toLowerCase -> filter non-empty + warn on no-@ entries (Codex Optional: warn not reject -- too sharp for a latent fix). Lookup also trim().toLowerCase(). Standalone upstream-PR-able per Codex Q-I3. Tests cover whitespace, case-insensitive env, case-insensitive lookup, invalid-shape warn, empty/unset env, multi-entry. Signed-off-by: Michael Uray <michael.uray@gmail.com> Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * chore(authProviders): drop cookieHeaderLen from OIDC diagnostics (E4 amendment) Codex E4 amendment (optional, low-priority): hasCookieHeader is sufficient to diagnose missing session-cookie issues; the cookieHeaderLen byte count adds no operational value and is one more PII-adjacent surface to keep an eye on. Drop it from both diagnostic paths in pods/authProviders/src/openid.ts. Signed-off-by: Michael Uray <michael.uray@gmail.com> Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * auth(oidc): gate verbose callback diagnostics behind OIDC_DEBUG (L-AUTH-2) Bei jedem fehlgeschlagenen OIDC-Callback wurde ein umfangreicher Diag-Block (errStack/sessionKeys/host/…) auf error-Level geloggt -> anonymes Log- Flooding via wiederholter invalider Callbacks. Normalbetrieb loggt jetzt nur eine knappe Fehlerzeile; der volle Diag-Block nur mit OIDC_DEBUG=true. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * account(admin): drop non-email ADMIN_EMAILS entries unless explicitly allowed (L-AUTH-3) Ein Tippfehler wie ADMIN_EMAILS=admin,michel@... legte bislang einen unbeabsichtigten Admin-Identifier an (Eintrag ohne @ wurde still behalten). Jetzt werden @-lose Eintraege per Default verworfen (fail-closed) und nur bei ADMIN_EMAILS_ALLOW_LOGIN_ID=true als Login-ID akzeptiert; Warnung stets. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * auth(oidc): redirect to /login when provider auth yields no account (L-AUTH-4) handleProviderAuth liefert '' bei einem nicht aufloesbaren Login (z.B. kein Account + Signup disabled). Bisher erfolgte dann kein Redirect und next() lief ins Leere -> haengende/leere Antwort. Jetzt fail-closed: explizites 302 auf /login?error=oidc_no_account. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * test(account): use example.com fixtures in adminEmails tests Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * auth(oidc): retry strategy registration with capped backoff (L-AUTH-5) The account service registered the OIDC passport strategy only once at startup via a single fire-and-forget Issuer.discover(). A transient issuer outage in that one boot moment left the 'oidc' strategy permanently unregistered, producing 500 "Unknown authentication strategy" on every OpenID login until a manual container restart (observed: ~10 days). Make registration self-healing: - Extract registerOidcStrategyWithRetry(), which retries the ENTIRE registration chain (discover -> new Client -> new Strategy -> passport.use) with capped exponential backoff (5s, x2, cap 60s), unbounded. The loop only returns after passport.use succeeds, so a failure at client or strategy construction (e.g. temporarily incomplete issuer metadata during an IdP upgrade) is retried too instead of permanently disabling OIDC. discover and sleep are injectable for tests; passport is a structural { use } interface. It never rejects: the success log runs failure-safe AFTER passport.use and returns immediately, so a throwing logger can never re-enter the loop and register the strategy a second time; the failure warn is likewise guarded so a throwing sink cannot break the never-rejects contract. - Gate log flooding via shouldWarnOnAttempt(): warn on the first 5 attempts and every 60th thereafter (~24 warn lines/day at the 60s cap instead of ~1440), following the branch's L-AUTH-2 rationale; error stacks only under OIDC_DEBUG. Success logs once with the attempt count. - Wire it fire-and-forget in registerOpenid() and flip an oidcReady flag on success. The .then body is a plain boolean flip that cannot throw; a trailing .catch(() => {}) is belt-and-suspenders so this fire-and-forget chain can never surface an unhandled rejection. Routes stay registered synchronously. - During the pending window, /auth/openid responds 503 with Retry-After: 5 and a short text body instead of a 500 or a redirect (the login app ignores an error query param, and L-AUTH-4 only covers the callback path). Add the package's first tests: the retry helper in isolation (schedule, 60s cap, whole-attempt retry incl. client-construction failure, warn gating, OIDC_DEBUG both ways, a throwing success log that must not re-register, and a throwing failure warn that must not reject) and the registerOpenid wiring (synchronous route registration, 503 pending window, exactly-once strategy registration). Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * account(admin): keep non-email ADMIN_EMAILS entries by default; strict mode opt-in Review feedback on #10931: dropping entries without "@" by default would silently revoke admin rights on upgrade for deployments that use login-id style values. The repo's own dev/tests/ws-tests compose files ship ADMIN_EMAILS=admin,${PLATFORM_ADMIN_EMAILS} and tests/prepare-pg.sh creates that `admin` account. Restore the original semantics as the default: every non-empty entry is kept (trimmed + lowercased), and entries without "@" are logged once at startup so a typo stays visible. The fail-closed behaviour becomes an explicit opt-in via ADMIN_EMAILS_STRICT=true. ADMIN_EMAILS_ALLOW_LOGIN_ID, introduced earlier on this branch and never released, is removed so there is a single switch. Empty entries stay dropped. This is a deliberate tightening compared to the original `new Set(ADMIN_EMAILS.split(','))`, which put '' into the set for any empty entry (ADMIN_EMAILS='', ',admin', 'admin,', 'admin,,ops@...') and so made isAdminEmail('') true. loginOrSignUpWithProvider passes '' when the provider returns no email, so such a login received an admin token. isAdminEmail('') is now always false. Tests: default keeps `admin` and warns, strict drops, only the exact value "true" enables strict mode, no warning for clean values, empty entries in every position never make an empty email an admin, trim/lowercase coverage unchanged. Each test saves, clears and restores ADMIN_EMAILS and ADMIN_EMAILS_STRICT so none depends on the outer environment. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * auth(oidc): settle the passport callback wrapper on rejection; address review Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * auth(providers): pass only provider-verified emails; abort OIDC retry on shutdown; more callback tests Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> --------- Signed-off-by: Michael Uray <michael.uray@gmail.com> Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
1 parent 5bb9b2f commit 1870da7

9 files changed

Lines changed: 1254 additions & 43 deletions

File tree

‎pods/authProviders/src/__tests__/openid.test.ts‎

Lines changed: 483 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import Router from 'koa-router'
2+
import { Strategy as GitHubStrategy } from 'passport-github2'
3+
import { registerGithub } from '../github'
4+
import { registerGoogle } from '../google'
5+
import { handleProviderAuth } from '../utils'
6+
7+
jest.mock('passport-google-oauth20', () => ({ Strategy: jest.fn() }))
8+
jest.mock('passport-github2', () => ({ Strategy: jest.fn() }))
9+
jest.mock('../utils', () => ({
10+
...jest.requireActual('../utils'),
11+
handleProviderAuth: jest.fn()
12+
}))
13+
14+
function makeMeasureCtx (): any {
15+
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }
16+
}
17+
18+
function setupProvider (provider: 'google' | 'github'): { router: Router<any, any> } {
19+
const passport: any = { use: jest.fn(), authenticate: jest.fn() }
20+
const router = new Router<any, any>()
21+
const register = provider === 'google' ? registerGoogle : registerGithub
22+
register(
23+
makeMeasureCtx(),
24+
passport,
25+
router,
26+
'http://accounts.example.com',
27+
Promise.resolve({} as any),
28+
'http://front.example.com',
29+
{}
30+
)
31+
return { router }
32+
}
33+
34+
async function invokeProviderHandler (
35+
router: Router<any, any>,
36+
provider: 'google' | 'github',
37+
user: any
38+
): Promise<void> {
39+
const layer = router.stack.find((entry) => entry.path === `/auth/${provider}/callback`)
40+
expect(layer).toBeDefined()
41+
await (layer as any).stack[1]({ query: {}, state: { user }, redirect: jest.fn() }, async () => {})
42+
}
43+
44+
describe('provider email verification', () => {
45+
const env = process.env
46+
47+
beforeEach(() => {
48+
jest.clearAllMocks()
49+
process.env = {
50+
...env,
51+
GOOGLE_CLIENT_ID: 'google-client-id',
52+
GOOGLE_CLIENT_SECRET: 'google-client-secret',
53+
GITHUB_CLIENT_ID: 'github-client-id',
54+
GITHUB_CLIENT_SECRET: 'github-client-secret'
55+
}
56+
;(handleProviderAuth as jest.Mock).mockResolvedValue('http://front.example.com/login/auth')
57+
})
58+
59+
afterEach(() => {
60+
process.env = env
61+
})
62+
63+
test('Google passes an email to handleProviderAuth only when email_verified is true', async () => {
64+
const { router } = setupProvider('google')
65+
66+
await invokeProviderHandler(router, 'google', {
67+
emails: [{ value: 'admin@example.com', verified: false }],
68+
name: { givenName: 'Ada', familyName: 'Lovelace' }
69+
})
70+
expect(handleProviderAuth).toHaveBeenLastCalledWith(
71+
expect.anything(),
72+
expect.anything(),
73+
expect.anything(),
74+
expect.anything(),
75+
'google',
76+
undefined,
77+
expect.anything(),
78+
undefined,
79+
'Ada',
80+
'Lovelace',
81+
expect.anything(),
82+
undefined
83+
)
84+
85+
await invokeProviderHandler(router, 'google', {
86+
emails: [{ value: 'admin@example.com', verified: true }],
87+
name: { givenName: 'Ada', familyName: 'Lovelace' }
88+
})
89+
expect(handleProviderAuth).toHaveBeenLastCalledWith(
90+
expect.anything(),
91+
expect.anything(),
92+
expect.anything(),
93+
expect.anything(),
94+
'google',
95+
undefined,
96+
expect.anything(),
97+
'admin@example.com',
98+
'Ada',
99+
'Lovelace',
100+
expect.anything(),
101+
undefined
102+
)
103+
})
104+
105+
test('GitHub passes only a primary verified email from its fetched emails API response', async () => {
106+
const { router } = setupProvider('github')
107+
108+
expect(GitHubStrategy).toHaveBeenCalledWith(expect.objectContaining({ allRawEmails: true }), expect.any(Function))
109+
110+
await invokeProviderHandler(router, 'github', {
111+
emails: [
112+
{ value: 'unverified@example.com', primary: true, verified: false },
113+
{ value: 'non-primary@example.com', primary: false, verified: true }
114+
],
115+
username: 'octocat'
116+
})
117+
expect(handleProviderAuth).toHaveBeenLastCalledWith(
118+
expect.anything(),
119+
expect.anything(),
120+
expect.anything(),
121+
expect.anything(),
122+
'github',
123+
undefined,
124+
expect.anything(),
125+
undefined,
126+
'octocat',
127+
'',
128+
expect.anything(),
129+
undefined
130+
)
131+
132+
await invokeProviderHandler(router, 'github', {
133+
emails: [{ value: 'admin@example.com', primary: true, verified: true }],
134+
username: 'octocat'
135+
})
136+
expect(handleProviderAuth).toHaveBeenLastCalledWith(
137+
expect.anything(),
138+
expect.anything(),
139+
expect.anything(),
140+
expect.anything(),
141+
'github',
142+
undefined,
143+
expect.anything(),
144+
'admin@example.com',
145+
'octocat',
146+
'',
147+
expect.anything(),
148+
undefined
149+
)
150+
})
151+
152+
test('GitHub treats an email without fetched primary and verification metadata as unverified', async () => {
153+
const { router } = setupProvider('github')
154+
155+
await invokeProviderHandler(router, 'github', {
156+
emails: [{ value: 'profile@example.com' }],
157+
username: 'octocat'
158+
})
159+
160+
expect(handleProviderAuth).toHaveBeenLastCalledWith(
161+
expect.anything(),
162+
expect.anything(),
163+
expect.anything(),
164+
expect.anything(),
165+
'github',
166+
undefined,
167+
expect.anything(),
168+
undefined,
169+
'octocat',
170+
'',
171+
expect.anything(),
172+
undefined
173+
)
174+
})
175+
})

‎pods/authProviders/src/github.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export function registerGithub (
2929
clientID: GITHUB_CLIENT_ID,
3030
clientSecret: GITHUB_CLIENT_SECRET,
3131
callbackURL: concatLink(accountsUrl, redirectURL),
32+
// passport-github2 otherwise keeps only the primary address and drops
33+
// the verification metadata returned by GitHub's emails API.
34+
allRawEmails: true,
3235
passReqToCallback: true
3336
},
3437
function (req: any, accessToken: string, refreshToken: string, profile: any, done: any) {
@@ -56,7 +59,9 @@ export function registerGithub (
5659
})(ctx, next)
5760
},
5861
async (ctx, next) => {
59-
const email = ctx.state.user.emails?.[0]?.value
62+
const verifiedEmail = ctx.state.user.emails?.find(
63+
(candidate: any) => candidate.primary === true && candidate.verified === true
64+
)?.value
6065
const nameParts = (ctx.state.user.displayName ?? ctx.state.user.username ?? '').split(' ')
6166
const first: string = nameParts[0] ?? ''
6267
const last: string = nameParts.slice(1).join(' ')
@@ -70,7 +75,7 @@ export function registerGithub (
7075
'github',
7176
ctx.query?.state,
7277
ctx.state?.user,
73-
email,
78+
verifiedEmail,
7479
first,
7580
last,
7681
{ type: SocialIdType.GITHUB, value: ctx.state.user.username },

‎pods/authProviders/src/google.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export function registerGoogle (
6060
},
6161
async (ctx, next) => {
6262
measureCtx.info('Provider auth success', { type: 'google', user: ctx.state?.user })
63+
// passport-google-oauth20 maps Google's email_verified claim to this field.
6364
const email = ctx.state.user.emails?.[0]?.value
65+
const verifiedEmail = ctx.state.user.emails?.[0]?.verified === true ? email : undefined
6466
const first = ctx.state.user.name.givenName
6567
const last = ctx.state.user.name.familyName
6668
const db = await dbPromise
@@ -73,7 +75,7 @@ export function registerGoogle (
7375
'google',
7476
ctx.query?.state,
7577
ctx.state?.user,
76-
email,
78+
verifiedEmail,
7779
first,
7880
last,
7981
{ type: SocialIdType.GOOGLE, value: email },

‎pods/authProviders/src/index.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export type AuthProvider = (
2020
db: Promise<AccountDB>,
2121
frontUrl: string,
2222
brandings: BrandingMap,
23-
signUpDisabled?: boolean
23+
signUpDisabled?: boolean,
24+
shutdownSignal?: AbortSignal
2425
) => ProviderInfo | undefined
2526

2627
export function registerProviders (
@@ -31,7 +32,8 @@ export function registerProviders (
3132
serverSecret: string,
3233
frontUrl: string | undefined,
3334
brandings: BrandingMap,
34-
signUpDisabled: boolean = false
35+
signUpDisabled: boolean = false,
36+
shutdownSignal?: AbortSignal
3537
): void {
3638
const accountsUrl = process.env.ACCOUNTS_URL
3739
if (accountsUrl === undefined) {
@@ -66,7 +68,7 @@ export function registerProviders (
6668
const res: ProviderInfo[] = []
6769
const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid]
6870
for (const provider of providers) {
69-
const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings, signUpDisabled)
71+
const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings, signUpDisabled, shutdownSignal)
7072
if (value !== undefined) res.push(value)
7173
}
7274

0 commit comments

Comments
 (0)