Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ USE_POSTGRES=
# Frontend Configuration
FRONTEND_URL=

# Social-broker return-origin allowlist (FuzeFront#352). Comma-separated list
# of full origins (scheme + host, no path/trailing slash) the server-brokered
# social sign-in flow (GET /api/v1/security/social/{provider}/start|callback)
# may redirect the browser back to INSTEAD OF FRONTEND_URL, for a consumer
# that proxies /api/v1/security/* same-origin from its own domain. Matched by
# EXACT Host header only — no wildcard/subdomain matching. Empty/unset = no
# additional origins (default; every deployment is unaffected).
# Example: SECURITY_SOCIAL_RETURN_ORIGINS=https://marketplace.mendysrobotics.com,https://live.mendysrobotics.com
SECURITY_SOCIAL_RETURN_ORIGINS=

# Authentik Configuration - Uses shared FuzeInfra PostgreSQL and Redis
AUTHENTIK_DB_NAME=
AUTHENTIK_SECRET_KEY=
Expand Down
29 changes: 27 additions & 2 deletions backend/security/src/providers/IdentityProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ export interface SocialCallbackResult {
linked?: boolean;
/** For a link handshake, the neutral provider slug that was linked. */
provider?: string;
/**
* The allowlisted return origin captured at `/social/:provider/start`
* (see `providers/authentik/socialReturnOrigins.ts`), if the starting
* request's Host matched one. `undefined` means "no match" — the caller
* falls back to `appBaseUrl()`, exactly as before this field existed.
*/
returnOrigin?: string;
}

export interface M2MClientProvisionInput {
Expand Down Expand Up @@ -282,12 +289,30 @@ export interface IdentityProvider {
*/
passwordLogin(input: PasswordLoginInput, ctx?: SessionContext): Promise<BrokeredSession>;

/** Begin a social login; returns where to 302 the browser + anti-forgery state. */
startSocialLogin(provider: string, redirectTo?: string): Promise<SocialLoginStart>;
/**
* Begin a social login; returns where to 302 the browser + anti-forgery state.
* `returnOrigin`, when provided, is an ALREADY-ALLOWLISTED origin (see
* `providers/authentik/socialReturnOrigins.ts`) the finished handshake
* should redirect back to instead of the ambient tenant's `appBaseUrl()` —
* e.g. a consumer proxying this API same-origin from its own domain
* (FuzeFront#352). Omit to keep today's `appBaseUrl()` behaviour.
*/
startSocialLogin(provider: string, redirectTo?: string, returnOrigin?: string): Promise<SocialLoginStart>;

/** Complete the social handshake; returns a single-use opaque code + return path. */
brokerCallback(input: SocialCallbackInput, ctx?: SessionContext): Promise<SocialCallbackResult>;

/**
* Non-consuming peek at the return origin recorded for a still-pending
* `state` (does NOT delete it — `brokerCallback` remains the sole
* consumer). Lets a callback route pick the right redirect destination for
* an error that occurs before/without calling `brokerCallback` (e.g. the
* provider itself reports `error=access_denied`). Optional: providers that
* don't support per-request return origins simply omit it, and callers
* fall back to `appBaseUrl()`.
*/
peekSocialReturnOrigin?(state: string): string | undefined;

/** Exchange a single-use opaque code for a session. Rejects on unknown/expired code. */
exchangeCode(code: string): Promise<BrokeredSession>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ interface SocialState {
* externalize for multi-replica — see [[Redis externalization]].
*/
mode: 'brokered' | 'source'
/**
* Allowlisted return origin captured at start (FuzeFront#352) — see
* `socialReturnOrigins.ts`. `undefined` = use the ambient tenant's
* `appBaseUrl()`, exactly as before this field existed.
*/
returnOrigin?: string
}
interface MfaChallenge {
userId: string
Expand Down Expand Up @@ -423,7 +429,7 @@ export class AuthentikIdentityProvider implements IdentityProvider {
// so no Authentik `/if/*` UI is ever rendered. The legacy Authentik
// `/source/oauth/*` source-redirect path is kept as a fallback (flag off) until
// the brokered path is proven.
async startSocialLogin(provider: string, redirectTo = '/'): Promise<SocialLoginStart> {
async startSocialLogin(provider: string, redirectTo = '/', returnOrigin?: string): Promise<SocialLoginStart> {
if (provider !== 'google') {
throw new InvalidInputError(`unsupported social provider: ${provider}`)
}
Expand All @@ -432,9 +438,9 @@ export class AuthentikIdentityProvider implements IdentityProvider {
throw new InvalidInputError('redirectTo must be a same-origin path')
}
if (googleBrokeredEnabled()) {
return this.startGoogleBrokered(redirectTo)
return this.startGoogleBrokered(redirectTo, returnOrigin)
}
return this.startSocialLoginViaSource(provider, redirectTo)
return this.startSocialLoginViaSource(provider, redirectTo, returnOrigin)
}

/**
Expand All @@ -444,7 +450,7 @@ export class AuthentikIdentityProvider implements IdentityProvider {
* verifier is held in the process-local Map (single replica) —
* TODO(redis): externalize for multi-replica — see [[Redis externalization]].
*/
private async startGoogleBrokered(redirectTo: string): Promise<SocialLoginStart> {
private async startGoogleBrokered(redirectTo: string, returnOrigin?: string): Promise<SocialLoginStart> {
if (!this.googleClient.isInitialized()) {
await this.googleClient.initialize()
}
Expand All @@ -455,14 +461,19 @@ export class AuthentikIdentityProvider implements IdentityProvider {
redirectTo,
expiresAt: this.now() + 10 * 60_000,
mode: 'brokered',
returnOrigin,
})
// `url` is the absolute accounts.google.com authorize URL — the ONLY external
// host the browser is allowed to see besides app.fuzefront.com.
return { redirectUrl: url, state, codeVerifier }
}

/** Legacy Authentik source-redirect start (fallback; browser transits `/if/*`). */
private async startSocialLoginViaSource(provider: string, redirectTo = '/'): Promise<SocialLoginStart> {
private async startSocialLoginViaSource(
provider: string,
redirectTo = '/',
returnOrigin?: string
): Promise<SocialLoginStart> {
if (!this.oidc.isInitialized()) {
await this.oidc.ensureInitialized()
}
Expand Down Expand Up @@ -498,6 +509,7 @@ export class AuthentikIdentityProvider implements IdentityProvider {
redirectTo,
expiresAt: this.now() + 10 * 60_000,
mode: 'source',
returnOrigin,
})
// codeVerifier is handed back so the route can persist it in an HttpOnly
// cookie (`oidc_cv`) for the replica-agnostic OIDC callback.
Expand Down Expand Up @@ -563,7 +575,18 @@ export class AuthentikIdentityProvider implements IdentityProvider {
user: session.user,
expiresAt: this.now() + CODE_TTL_MS,
})
return { code, redirectTo: st.redirectTo || '/' }
return { code, redirectTo: st.redirectTo || '/', returnOrigin: st.returnOrigin }
}

/**
* Non-consuming peek at the return origin recorded for a pending `state` —
* used by the callback ROUTE to pick a redirect destination for an error
* that short-circuits before (or instead of) calling `brokerCallback`
* (e.g. the provider's own `error=access_denied`). Does not delete the
* entry: `brokerCallback` remains the single place state is consumed.
*/
peekSocialReturnOrigin(state: string): string | undefined {
return this.socialStates.get(state)?.returnOrigin
}

async exchangeCode(code: string): Promise<BrokeredSession> {
Expand Down
99 changes: 99 additions & 0 deletions backend/security/src/providers/authentik/socialReturnOrigins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Return-origin allowlist for the brokered social sign-in flow
* (FuzeFront#352 — MendysRobotics marketplace/live).
*
* By default, `startSocialLogin` / `brokerCallback` in
* `AuthentikIdentityProvider` send the browser's single-use opaque `?code=`
* back to `${appBaseUrl()}${redirectTo}` — the ambient identity tenant's
* configured app origin (`providers/authentik/config.ts` /
* `providers/authentik/tenants.ts`). That is correct for FuzeFront's own SPA,
* but a consumer that proxies `/api/v1/security/*` SAME-ORIGIN from its own
* domain (MendysRobotics' `marketplace`/`live` SPAs) needs the code returned
* to ITS origin instead of `app.fuzefront.com`.
*
* ── Why this is NOT the multi-tenant identity registry ──────────────────────
* `SECURITY_TENANTS` (tenants.ts) already resolves an inbound request's Host
* to a whole separate identity tenant — its OWN Authentik instance, OIDC
* client, admin token, etc. — and switches the service into a mode where any
* UNDECLARED host is REJECTED with a hard 400 (tenants.ts's "FAIL CLOSED").
* Turning that on would require enumerating EVERY host that legitimately
* reaches security-service today — Traefik (`app.fuzefront.com`), and every
* in-cluster caller's Service-DNS Host header (provisioning-service,
* config-service, selection-list-service all call
* `http://fuzefront-security:3002/api/v1/security/*`, which is also mounted
* behind `tenantContext`) — or those callers start getting rejected the
* moment `SECURITY_TENANTS` is set. That is a prod-wide identity-routing
* change with a large, not-fully-enumerable-from-this-repo blast radius, for
* a request that is only "let the social broker return to two more origins".
* So this module is a separate, narrower, ADDITIVE-ONLY mechanism: it never
* changes which identity backend serves a request, only which origin the
* *finished* broker redirect targets — and it changes nothing when no host
* matches (today's behaviour, byte-for-byte, for every existing deployment).
*
* ── Matching rule ────────────────────────────────────────────────────────
* EXACT origin match only — no wildcard, no subdomain pattern, no
* startsWith/substring match. The destination receives the caller's fresh
* single-use sign-in code, so an over-broad allowlist here is a direct
* open-redirect / auth-code-leak vector. Matched on the inbound request's raw
* `Host` header, normalised the same way `tenants.ts` normalises it (see
* `normaliseHost`'s doc comment there for why raw `Host` rather than
* `X-Forwarded-Host` is the right signal here too) — captured once at
* `/social/:provider/start` and carried in the broker's server-side state
* (`AuthentikIdentityProvider.socialStates`) so the later callback — which
* physically arrives back on FuzeFront's own host via Google's registered
* `redirect_uri`, not on the origin that started the flow — knows where to
* send the browser next.
*/
import { Request } from 'express'
import { normaliseHost } from './tenants'

/**
* `SECURITY_SOCIAL_RETURN_ORIGINS` — comma-separated list of full origins
* (`https://host`, scheme required, no path/query/fragment/trailing slash)
* the social broker may redirect the browser back to, in addition to the
* ambient tenant's own `appBaseUrl()`. Unset or empty = no additional
* origins; every existing deployment is unaffected.
*
* Re-parsed on every call, deliberately: this is a plain env value (no
* secret material — it is just a list of public web origins), cheap to
* re-read, and tests set/clear `process.env.SECURITY_SOCIAL_RETURN_ORIGINS`
* between cases without needing a reset hook.
*/
function parseAllowedReturnOrigins(): Map<string, string> {
const raw = process.env.SECURITY_SOCIAL_RETURN_ORIGINS
const byHost = new Map<string, string>()
if (!raw) return byHost
for (const entry of raw.split(',')) {
const candidate = entry.trim().replace(/\/+$/, '')
if (!candidate) continue
let url: URL
try {
url = new URL(candidate)
} catch {
// Malformed entry — skip it rather than crash the process. This value
// is not validated at deploy time the way SECURITY_TENANTS is.
continue
}
// Require a bare origin. A path/query/fragment on an allowlist entry is
// very likely a misconfiguration, and silently truncating it to just the
// host would allowlist more than was actually written down.
if ((url.pathname !== '/' && url.pathname !== '') || url.search || url.hash) continue
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue
const host = normaliseHost(url.host)
if (!host) continue
byHost.set(host, `${url.protocol}//${url.host}`)
}
return byHost
}

/**
* Resolve the return origin for a `/social/:provider/start` request, or
* `undefined` if its Host does not exactly match an allowlisted origin (the
* overwhelmingly common case — callers then keep using `appBaseUrl()`).
*/
export function resolveAllowedReturnOrigin(req: Pick<Request, 'headers'>): string | undefined {
const allowed = parseAllowedReturnOrigins()
if (allowed.size === 0) return undefined
const host = normaliseHost(req.headers.host)
return host ? allowed.get(host) : undefined
}
41 changes: 33 additions & 8 deletions backend/security/src/routes/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
emailVerificationEnabled,
} from '../providers/authentik/AuthentikIdentityProvider'
import { appBaseUrl } from '../providers/authentik/config'
import { resolveAllowedReturnOrigin } from '../providers/authentik/socialReturnOrigins'
import { findUserPk, setUserPassword, PasswordPolicyError } from '../providers/authentik/accountApi'
import type {
BrokeredSession,
Expand Down Expand Up @@ -276,7 +277,15 @@ router.post('/session/password/reset-confirm', async (req: Request, res: Respons
router.get('/social/:provider/start', async (req: Request, res: Response) => {
try {
const redirectTo = typeof req.query.redirectTo === 'string' ? req.query.redirectTo : '/'
const { redirectUrl, state, codeVerifier } = await getIdentityProvider().startSocialLogin(req.params.provider, redirectTo)
// Exact-match allowlist only (FuzeFront#352) — see socialReturnOrigins.ts.
// undefined when the request's Host isn't allowlisted, which keeps the
// existing appBaseUrl()-only behaviour for every other caller.
const returnOrigin = resolveAllowedReturnOrigin(req)
const { redirectUrl, state, codeVerifier } = await getIdentityProvider().startSocialLogin(
req.params.provider,
redirectTo,
returnOrigin
)
// The authorize URL's redirect_uri is the OIDC client's registered callback
// (`/api/auth/oidc/callback`), so the browser returns THERE after Google
// consent. That handler is replica-agnostic via the oidc_state + oidc_cv
Expand All @@ -297,6 +306,13 @@ router.get('/social/:provider/start', async (req: Request, res: Response) => {

// GET /v1/security/social/callback — broker callback, 302 back to app with ?code=
router.get('/social/callback', async (req: Request, res: Response) => {
// Resolved ONCE, before the exchange, via a non-consuming peek at the
// pending state (FuzeFront#352) — so an allowlisted return origin also
// applies to the fail-closed error redirect below, not just the success
// path. Falls back to the ambient tenant's appBaseUrl() when the state is
// unknown/expired or the provider declares no return-origin support.
const statePeek = typeof req.query.state === 'string' ? req.query.state : ''
const destOrigin = getIdentityProvider().peekSocialReturnOrigin?.(statePeek) ?? appBaseUrl()
try {
const code = typeof req.query.code === 'string' ? req.query.code : ''
const state = typeof req.query.state === 'string' ? req.query.state : ''
Expand All @@ -308,16 +324,17 @@ router.get('/social/callback', async (req: Request, res: Response) => {
// Clear the state cookie; append the FuzeFront opaque code (never a token).
res.setHeader('Set-Cookie', ['sec_social_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/'])
const sep = result.redirectTo.includes('?') ? '&' : '?'
const origin = result.returnOrigin ?? destOrigin
// A LINK handshake mints no session, so there is no code to redeem — send
// the browser back with a neutral confirmation instead.
const dest = result.linked
? `${appBaseUrl()}${result.redirectTo}${sep}linked=${encodeURIComponent(result.provider ?? '')}`
: `${appBaseUrl()}${result.redirectTo}${sep}code=${encodeURIComponent(result.code)}`
? `${origin}${result.redirectTo}${sep}linked=${encodeURIComponent(result.provider ?? '')}`
: `${origin}${result.redirectTo}${sep}code=${encodeURIComponent(result.code)}`
res.redirect(302, dest)
} catch (err) {
// Fail-closed: send the browser back to the app with a neutral error.
res.setHeader('Set-Cookie', ['sec_social_state=; HttpOnly; Secure; SameSite=Lax; Max-Age=0; Path=/'])
res.redirect(302, `${appBaseUrl()}/?error=authentication_failed`)
res.redirect(302, `${destOrigin}/?error=authentication_failed`)
}
})

Expand All @@ -331,10 +348,17 @@ router.get('/social/callback', async (req: Request, res: Response) => {
// State + PKCE are held server-side in the provider's Map (single replica) — no
// cookie round-trip needed for this path.
router.get('/social/google/callback', async (req: Request, res: Response) => {
// Resolved ONCE, up front, via a non-consuming peek at the pending state
// (FuzeFront#352) — applies the allowlisted return origin to every exit
// from this handler, including Google's own error param below and the
// fail-closed catch. Falls back to appBaseUrl() when the state is
// unknown/expired/absent or the provider declares no return-origin support.
const statePeek = typeof req.query.state === 'string' ? req.query.state : ''
const destOrigin = getIdentityProvider().peekSocialReturnOrigin?.(statePeek) ?? appBaseUrl()
// Google can return an explicit error (e.g. the user denied consent). Never
// leak provider detail to the app — send a neutral error back.
if (typeof req.query.error === 'string' && req.query.error) {
res.redirect(302, `${appBaseUrl()}/?error=authentication_failed`)
res.redirect(302, `${destOrigin}/?error=authentication_failed`)
return
}
try {
Expand All @@ -346,15 +370,16 @@ router.get('/social/google/callback', async (req: Request, res: Response) => {
if (!code || !state) throw new InvalidInputError('code and state are required')
const result = await getIdentityProvider().brokerCallback({ code, state, iss }, sessionContext(req))
const sep = result.redirectTo.includes('?') ? '&' : '?'
const origin = result.returnOrigin ?? destOrigin
// A LINK handshake mints no session (no code to redeem) — return a neutral
// confirmation instead.
const dest = result.linked
? `${appBaseUrl()}${result.redirectTo}${sep}linked=${encodeURIComponent(result.provider ?? '')}`
: `${appBaseUrl()}${result.redirectTo}${sep}code=${encodeURIComponent(result.code)}`
? `${origin}${result.redirectTo}${sep}linked=${encodeURIComponent(result.provider ?? '')}`
: `${origin}${result.redirectTo}${sep}code=${encodeURIComponent(result.code)}`
res.redirect(302, dest)
} catch (err) {
// Fail-closed: neutral error back to the app. Never surface tokens or vendor.
res.redirect(302, `${appBaseUrl()}/?error=authentication_failed`)
res.redirect(302, `${destOrigin}/?error=authentication_failed`)
}
})

Expand Down
Loading
Loading