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
7 changes: 7 additions & 0 deletions .changeset/oidc-session-bind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'seamless-auth-api': minor
---

The API now owns which sessions a provider sign-in was made on. The provider service binds a session to a client with `POST /oidc/sessions/:id/bind`, presenting the user's own access token for that session, and the API answers account and auth context lookups, signs provider tokens, and accepts a session revoke only for bound sessions. Account notices need the same binding. Device-flow tokens no longer carry `amr` or `auth_time`, as they already did not carry `acr`.

This raises the provider service contract to version 3, so `seamless-auth-oidc-provider` must be upgraded with the API. Sessions signed in to provider clients before the upgrade need to sign in again to get new provider tokens.
137 changes: 137 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13481,6 +13481,143 @@
}
}
},
"/oidc/sessions/{id}/bind": {
"post": {
"summary": "Bind a session to the client a provider sign-in is for",
"tags": ["OpenID Provider service"],
"security": [{ "serviceToken": [] }],
"parameters": [
{
"schema": { "type": "string", "minLength": 1, "maxLength": 512 },
"required": true,
"name": "id",
"in": "path"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"clientId": { "type": "string", "minLength": 1, "maxLength": 512 },
"accessToken": { "type": "string", "minLength": 1, "maxLength": 8192 }
},
"required": ["clientId", "accessToken"]
}
}
}
},
"responses": {
"400": {
"description": "HTTP 400",
"content": {
"application/json": {
"example": {
"error": "string",
"message": "string",
"details": { "issues": [null] }
},
"schema": {
"type": "object",
"properties": {
"error": { "type": "string" },
"message": { "type": "string" },
"details": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": { "anyOf": [{ "type": "string" }, { "type": "number" }] }
},
"code": { "type": "string" },
"message": { "type": "string" }
},
"required": ["path", "code", "message"]
}
}
},
"required": ["issues"]
}
},
"required": ["error"]
}
}
}
},
"401": {
"description": "HTTP 401",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"403": {
"description": "HTTP 403",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"404": {
"description": "HTTP 404",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"429": {
"description": "HTTP 429",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
},
"500": {
"description": "HTTP 500",
"content": {
"application/json": {
"example": { "message": "string", "error": "string" },
"schema": {
"type": "object",
"properties": { "message": { "type": "string" }, "error": { "type": "string" } },
"required": ["error"]
}
}
}
}
}
}
},
"/oidc/sessions/{id}/revoke": {
"post": {
"summary": "Revoke the session behind a provider sign-in",
Expand Down
20 changes: 16 additions & 4 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.3.1",
"@seamless-auth/messaging-aws": "^0.3.1",
"@seamless-auth/messaging-twilio": "^0.3.1",
"@seamless-auth/types": "^0.34.0",
"@seamless-auth/types": "^0.35.0",
"@simplewebauthn/server": "^14.0.3",
"base64url": "^3.0.1",
"bcrypt-ts": "^9.0.2",
Expand Down
34 changes: 32 additions & 2 deletions src/controllers/oidcEdge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type OidcPayloadUidParams,
type OidcPayloadUpsertRequest,
type OidcPayloadUserCodeParams,
type OidcSessionBindRequest,
type OidcSessionIdParams,
type OidcSessionQuery,
type OidcSessionRevokeRequest,
Expand All @@ -39,6 +40,7 @@ import {
verifyOidcClientSecret,
} from '../services/oidcClientRegistry.js';
import {
bindOidcSession,
consumeOidcPayload,
destroyOidcPayload,
ExpiryTooLongError,
Expand All @@ -47,12 +49,13 @@ import {
findOidcPayloadByUid,
findOidcPayloadByUserCode,
InvalidSessionIdError,
isOidcSessionBound,
revokeOidcPayloadsByGrant,
upsertOidcPayload,
} from '../services/oidcPayloadStore.js';
import { getOidcPublicJwks } from '../services/oidcProviderKeys.js';
import { signForOidcEdge } from '../services/oidcSigning.js';
import { revokeSessionChain } from '../services/sessionService.js';
import { revokeSessionChain, validateBearerToken } from '../services/sessionService.js';
import getLogger from '../utils/logger.js';

const logger = getLogger('oidcEdge');
Expand Down Expand Up @@ -212,13 +215,40 @@ export async function getOidcSessionAuthContext(req: Req<OidcSessionIdParams>, r
return context ? res.status(200).json(context) : res.status(404).json(NOT_FOUND);
}

/**
* The edge holds a user's access token only while that user signs in through it, so a
* binding made with one is a sign-in the user really made, for the session the token was
* issued under.
*/
export async function postOidcSessionBind(
req: Req<OidcSessionIdParams, OidcSessionBindRequest>,
res: Response,
) {
const { clientId, accessToken } = req.body;
const validated = await validateBearerToken(accessToken, 'access');

if (!validated || validated.sessionId !== req.params.id) {
return res.status(403).json({ error: 'invalid_access_token' });
}
if (validated.recovery) {
return res.status(403).json({ error: 'enrollment_required' });
}
if (!(await findActiveOidcClient(clientId))) return res.status(404).json(NOT_FOUND);

await bindOidcSession(req.params.id, validated.user.id, clientId);
return res.status(204).end();
}

export async function postOidcSessionRevoke(
req: Req<OidcSessionIdParams, OidcSessionRevokeRequest>,
res: Response,
) {
const session = isUuid(req.params.id) ? await Session.findByPk(req.params.id) : null;

if (!session) return res.status(404).json(NOT_FOUND);
// Only a session someone signed in to a client with through the edge is the edge's to end.
if (!session || !(await isOidcSessionBound(session.id))) {
return res.status(404).json(NOT_FOUND);
}

// From the recorded row forward, so the rotations the edge's adapter made since the
// provider sign-in are revoked with it.
Expand Down
Loading
Loading