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
9 changes: 9 additions & 0 deletions .changeset/oauth-state-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'seamless-auth-api': patch
---

Bind the OAuth `state` to the client that started the sign-in (#463). The state was signed, expiring and single use, but anyone holding an unconsumed state and its provider code could finish the sign-in in another browser and sign that browser in to their own account (login CSRF, RFC 9700 section 4.7).

`POST /oauth/:providerId/start` now also returns a random `binding`, and its SHA-256 is signed into the state. `POST /oauth/:providerId/callback` must send that `binding` back, and a callback with a missing or different one is refused with 400 `Invalid OAuth state` before the provider is contacted. The `oauth_login_failed` auth event records `state_binding_missing` or `state_binding_mismatch`.

This breaks OAuth sign-in for clients that predate the binding. Upgrade `@seamless-auth/client`, `@seamless-auth/react` or `@seamless-auth/react-native`, which keep and send it; server adapters pass it through unchanged. A direct API integration must store `binding` from `/start` and send it with the callback. Until every client is upgraded, `OAUTH_ALLOW_UNBOUND_STATE=true` accepts a callback without one. It reopens the login CSRF while set, warns at boot, and is removed in v0.20.
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ then daily, and each run also logs the audit chain head as an external anchor. S
| `TOTP_SECRET_ENCRYPTION_KEY` | No | falls back to the service token (deprecated, required from v0.20) | 32-byte base64url/base64/hex key for encrypted TOTP secrets. Production requires this or the service token. |
| `OAUTH_STATE_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | Dedicated signing secret for OAuth state. |
| `DECOY_SUBJECT_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | HMAC key the decoy pre-auth subjects on `POST /login` derive from. Rotating it re-maps which subject an unknown identifier gets, which is harmless: decoys are stateless and pre-auth tokens live five minutes. |
| `OAUTH_ALLOW_UNBOUND_STATE` | No | `false` | Deprecated, removed in v0.20. `true` accepts an OAuth callback without the `binding` from `/start`, for clients that predate it. Reopens login CSRF while set; see [docs/oauth.md](./oauth.md#browser-flow). |

> Prefer setting dedicated secrets in production rather than relying on the service token
> fallbacks. See [docs/production-operations.md](./production-operations.md).
Expand Down
19 changes: 16 additions & 3 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,24 @@ Example provider:
## Browser Flow

1. `GET /oauth/providers`
2. `POST /oauth/:providerId/start`
2. `POST /oauth/:providerId/start`. Keep the returned `binding` with the pending sign-in (for
example in `sessionStorage`, or in memory in a native app). Never send it to the provider.
3. Redirect browser to returned `authorizationUrl`.
4. Provider redirects back to your configured callback URL with `code` and `state`.
5. `POST /oauth/:providerId/callback`
6. Seamless Auth validates state, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session.
5. `POST /oauth/:providerId/callback` with `code`, `state` and the `binding` from step 2.
6. Seamless Auth validates state and its binding, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session.

The `binding` ties the `state` to the client that started the sign-in. The state travels through
the provider and the address bar, so without it anyone holding a state and its code could finish
the sign-in in another browser and sign that browser in to their own account (login CSRF). A
callback with a missing or different binding is refused with 400 `Invalid OAuth state`, and the
`oauth_login_failed` auth event records `state_binding_missing` or `state_binding_mismatch`. The
`@seamless-auth/client`, `@seamless-auth/react` and `@seamless-auth/react-native` SDKs keep and
send it for you; server adapters pass it through unchanged.

While upgrading clients that predate the binding, `OAUTH_ALLOW_UNBOUND_STATE=true` accepts a
callback that carries none. It reopens login CSRF for as long as it is set, logs a warning at boot,
and is removed in v0.20. A binding that is sent is always checked.

## Redirect Policy

Expand Down
9 changes: 6 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7934,7 +7934,8 @@
"example": {
"provider": { "id": "string", "name": "string", "scopes": [null] },
"state": "string",
"authorizationUrl": null
"authorizationUrl": null,
"binding": "string"
},
"schema": {
"type": "object",
Expand All @@ -7949,7 +7950,8 @@
"required": ["id", "name", "scopes"]
},
"state": { "type": "string" },
"authorizationUrl": { "type": "string", "format": "uri" }
"authorizationUrl": { "type": "string", "format": "uri" },
"binding": { "type": "string" }
},
"required": ["provider", "state", "authorizationUrl"]
}
Expand Down Expand Up @@ -8059,7 +8061,8 @@
"type": "object",
"properties": {
"code": { "type": "string", "minLength": 1 },
"state": { "type": "string", "minLength": 1 }
"state": { "type": "string", "minLength": 1 },
"binding": { "type": "string", "minLength": 1 }
},
"required": ["code", "state"]
}
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@seamless-auth/messaging": "^0.2.0",
"@seamless-auth/messaging-aws": "^0.2.0",
"@seamless-auth/messaging-twilio": "^0.2.0",
"@seamless-auth/types": "^0.28.0",
"@seamless-auth/types": "^0.29.0",
"@simplewebauthn/server": "^14.0.3",
"base64url": "^3.0.1",
"bcrypt-ts": "^9.0.2",
Expand Down
17 changes: 12 additions & 5 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) {
const config = await getSystemConfig();
const redirectUri = await resolveOAuthRedirectUri(provider, req.body.redirectUri);
const returnTo = allowedReturnTo(req.body.returnTo, config.origins);
const { state, payload: statePayload } = createOAuthState({
const {
state,
payload: statePayload,
binding,
} = createOAuthState({
providerId: provider.id,
redirectUri,
...(returnTo ? { returnTo } : {}),
Expand All @@ -81,6 +85,7 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) {
return res.json({
provider: serializeOAuthProvider(provider),
state,
binding,
authorizationUrl: buildOAuthAuthorizationUrl({
provider,
redirectUri,
Expand All @@ -104,24 +109,26 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) {

export async function finishOAuthLogin(req: RouteRequest, res: Response) {
const { providerId } = req.params;
const { code, state } = req.body;
const { code, state, binding } = req.body;
const provider = await getOAuthProvider(providerId);

if (!provider) {
return res.status(404).json({ error: 'OAuth provider not found' });
}

const statePayload = consumeOAuthState(state, provider.id);
const consumed = consumeOAuthState(state, provider.id, binding);

if (!statePayload) {
if (!consumed.ok) {
await AuthEventService.log({
type: 'oauth_login_failed',
req,
metadata: { providerId: provider.id, reason: 'invalid_state' },
metadata: { providerId: provider.id, reason: consumed.reason },
});
return res.status(400).json({ error: 'Invalid OAuth state' });
}

const statePayload = consumed.payload;

try {
const { accessToken, idToken } = await exchangeOAuthTokens({
provider,
Expand Down
5 changes: 4 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7940,7 +7940,8 @@ export interface paths {
* ]
* },
* "state": "string",
* "authorizationUrl": null
* "authorizationUrl": null,
* "binding": "string"
* }
*/
'application/json': {
Expand All @@ -7952,6 +7953,7 @@ export interface paths {
state: string;
/** Format: uri */
authorizationUrl: string;
binding?: string;
};
};
};
Expand Down Expand Up @@ -8074,6 +8076,7 @@ export interface paths {
'application/json': {
code: string;
state: string;
binding?: string;
};
};
};
Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { connectToDb } from './db.js';
import { initializeModels } from './models/index.js';
import { startAuditMaintenance } from './services/auditRetention.js';
import { initializeMetadataService } from './services/metadataServiceBootstrap.js';
import { warnIfUnboundOAuthStateAllowed } from './services/oauthService.js';
import { assertSchemaCurrent } from './services/schemaGuard.js';
import getLogger from './utils/logger.js';
import { initDevSigningKey } from './utils/signingKeyStore.js';
Expand All @@ -28,6 +29,7 @@ async function startServer() {
// First, so conflicting service token env vars stop boot before anything connects.
assertServiceTokenConfig();
assertDevModeIsLocal();
warnIfUnboundOAuthStateAllowed();

const models = await initializeModels();

Expand Down
81 changes: 73 additions & 8 deletions src/services/oauthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ export type OAuthStatePayload = {
redirectUri: string;
returnTo?: string;
nonce: string;
/** SHA-256 of the binding returned to the client that started the sign-in. */
bindingHash?: string;
createdAt: number;
};

export type OAuthStateRefusal =
'invalid_state' | 'state_binding_missing' | 'state_binding_mismatch';

export type ConsumedOAuthState =
{ ok: true; payload: OAuthStatePayload } | { ok: false; reason: OAuthStateRefusal };

export type OAuthProfile = {
subject: string;
email: string;
Expand Down Expand Up @@ -128,6 +136,26 @@ function purgeConsumedStates(now = Date.now()) {
}
}

/**
* Accepts a callback that carries no binding. Only for clients older than the binding,
* during an upgrade: without it a state can be finished by anyone who holds it.
*/
export function unboundOAuthStateAllowed(env: Record<string, string | undefined> = process.env) {
return env.OAUTH_ALLOW_UNBOUND_STATE?.trim().toLowerCase() === 'true';
}

export function warnIfUnboundOAuthStateAllowed(
env: Record<string, string | undefined> = process.env,
) {
if (!unboundOAuthStateAllowed(env)) return;

logger.warn(
'OAUTH_ALLOW_UNBOUND_STATE is set, so an OAuth callback without a binding is accepted and ' +
'a sign-in can be finished by a client other than the one that started it. Upgrade every ' +
'client to send the binding and unset it. It is deprecated and is removed in v0.20.',
);
}

function pkceEnabled(provider: OAuthProviderConfig) {
return provider.pkce !== false;
}
Expand Down Expand Up @@ -219,25 +247,37 @@ export async function resolveOAuthRedirectUri(
}

/**
* The signed state, and the payload that went into it.
* The signed state, the payload that went into it, and the binding.
*
* Both, because the caller needs the nonce and the rest of the payload to build the
* The payload too, because the caller needs the nonce and the rest of it to build the
* authorization URL. Returning only the string meant verifying it back immediately:
* a second HMAC, a decode, a parse and a re-check of fields the caller had just set.
*
* The binding goes only to the client that asked to start, never to the provider, and
* the callback must present it. The state alone travels through the provider and the
* browser's address bar, so without the binding anyone holding a state and its code
* could finish the sign-in in another browser and sign that browser in to their
* account (login CSRF, RFC 9700 section 4.7). The PKCE verifier is derived from the
* payload, so PKCE does not bind the flow to a client by itself.
*/
export function createOAuthState(payload: Omit<OAuthStatePayload, 'createdAt' | 'nonce'>): {
export function createOAuthState(
payload: Omit<OAuthStatePayload, 'createdAt' | 'nonce' | 'bindingHash'>,
): {
state: string;
payload: OAuthStatePayload;
binding: string;
} {
const binding = randomBytes(32).toString('base64url');
const statePayload: OAuthStatePayload = {
...payload,
nonce: randomBytes(16).toString('base64url'),
bindingHash: sha256Base64Url(binding),
createdAt: Date.now(),
};
const encodedPayload = base64UrlEncode(JSON.stringify(statePayload));
const signature = signPayload(encodedPayload);

return { state: `${encodedPayload}.${signature}`, payload: statePayload };
return { state: `${encodedPayload}.${signature}`, payload: statePayload, binding };
}

export function verifyOAuthState(state: string, providerId: string): OAuthStatePayload | null {
Expand All @@ -262,22 +302,47 @@ export function verifyOAuthState(state: string, providerId: string): OAuthStateP
return payload;
}

export function consumeOAuthState(state: string, providerId: string): OAuthStatePayload | null {
function checkOAuthStateBinding(
payload: OAuthStatePayload,
binding: string | undefined,
): OAuthStateRefusal | null {
if (!binding) {
Comment thread
Bccorb marked this conversation as resolved.
Dismissed
return unboundOAuthStateAllowed() ? null : 'state_binding_missing';
}

if (typeof payload.bindingHash !== 'string') return 'state_binding_mismatch';

return safeEqual(sha256Base64Url(binding), payload.bindingHash) ? null : 'state_binding_mismatch';
}

/**
* Checked before the replay cache records the state, so a callback presenting the
* wrong binding cannot use up a state its rightful client has yet to finish.
*/
export function consumeOAuthState(
state: string,
providerId: string,
binding?: string,
): ConsumedOAuthState {
const payload = verifyOAuthState(state, providerId);

if (!payload) return null;
if (!payload) return { ok: false, reason: 'invalid_state' };

const refusal = checkOAuthStateBinding(payload, binding);

if (refusal) return { ok: false, reason: refusal };

purgeConsumedStates();

const stateHash = hashStateForReplayCache(state);

if (consumedStateHashes.has(stateHash)) {
return null;
return { ok: false, reason: 'invalid_state' };
}

consumedStateHashes.set(stateHash, payload.createdAt + STATE_TTL_MS);

return payload;
return { ok: true, payload };
}

export function clearOAuthStateReplayCache() {
Expand Down
Loading
Loading