From fb528e3cde751f9077e82b23dfde734d284ed0d2 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 19:15:58 -0500 Subject: [PATCH] feat(oidc): let a backend client send account notices for its linked public clients Adds oidc_client_links with admin routes under /admin/applications/:id/links, an optional clientId on POST /applications/account-notices, and noticeActionOrigins on applications. Amends ADR 0001 D14. Closes #547 --- .changeset/oidc-client-links.md | 5 + adapter-manifest.json | 11 + docs/admin-operations.md | 12 + docs/adr/0001-openid-provider.md | 6 +- docs/adr/0002-native-and-desktop-apps.md | 28 +- openapi.json | 667 +++++++++++++++++- src/controllers/applicationNotices.ts | 14 +- src/controllers/oidcApplications.ts | 69 +- src/generated/api.ts | 618 +++++++++++++++- .../20261102120000-add-oidc-client-links.cjs | 47 ++ src/models/oidcClientLinks.ts | 47 ++ src/models/oidcClients.ts | 5 + src/routes/oidcApplications.routes.ts | 74 +- src/schemas/applicationNotices.requests.ts | 11 +- src/schemas/authEvent.types.ts | 2 + src/schemas/oidcApplications.requests.ts | 58 ++ src/schemas/oidcApplications.responses.ts | 51 ++ src/services/applicationNoticeService.ts | 39 +- src/services/oidcApplicationService.ts | 49 +- src/services/oidcClientLinkService.ts | 95 +++ tests/integration/admin/adminStepUp.spec.ts | 2 + .../applications/accountNotices.spec.ts | 156 ++++ .../integration/oidc/oidcApplications.spec.ts | 113 ++- .../integration/oidc/oidcClientLinks.spec.ts | 276 ++++++++ tests/postgres/oidcApplications.spec.ts | 67 ++ tests/setup/mocks.ts | 11 + tests/unit/models/models.spec.ts | 1 + 27 files changed, 2452 insertions(+), 82 deletions(-) create mode 100644 .changeset/oidc-client-links.md create mode 100644 src/migrations/20261102120000-add-oidc-client-links.cjs create mode 100644 src/models/oidcClientLinks.ts create mode 100644 src/schemas/oidcApplications.requests.ts create mode 100644 src/schemas/oidcApplications.responses.ts create mode 100644 src/services/oidcClientLinkService.ts create mode 100644 tests/integration/oidc/oidcClientLinks.spec.ts diff --git a/.changeset/oidc-client-links.md b/.changeset/oidc-client-links.md new file mode 100644 index 00000000..92cca617 --- /dev/null +++ b/.changeset/oidc-client-links.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +A confidential OpenID Provider application can send account notices for the public applications it acts for (ADR 0002, N4 and N5). An admin links them with `PUT /admin/applications/:id/links/:linkedId`, lists links with `GET /admin/applications/:id/links` and removes one with `DELETE`, each write with step-up and audited as `admin_oidc_application_link_added` or `admin_oidc_application_link_removed`. A link needs a confidential acting application and a different public linked one, both first-party or both third-party. `POST /applications/account-notices` takes an optional `clientId`: the caller must be linked to it, the grant check uses it and the template names it, and a client the caller is not linked to answers the same `404 notice_unknown_user` as an unknown user. Applications gain `noticeActionOrigins`, bare `https` origins that a notice's action link may point at besides the `https` redirect origins, and every application response lists `noticeActionOrigins`, `links` and `linkedFrom`. A new migration adds the `oidc_client_links` table and the column. ADR 0001 D14 is amended to match. diff --git a/adapter-manifest.json b/adapter-manifest.json index f8ccb498..372a6ad8 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -15,6 +15,17 @@ { "method": "GET", "path": "/admin/applications/{id}", "credential": "access" }, { "method": "PATCH", "path": "/admin/applications/{id}", "credential": "access" }, { "method": "PUT", "path": "/admin/applications/{id}/keys", "credential": "access" }, + { "method": "GET", "path": "/admin/applications/{id}/links", "credential": "access" }, + { + "method": "DELETE", + "path": "/admin/applications/{id}/links/{linkedId}", + "credential": "access" + }, + { + "method": "PUT", + "path": "/admin/applications/{id}/links/{linkedId}", + "credential": "access" + }, { "method": "GET", "path": "/admin/applications/{id}/resources", "credential": "access" }, { "method": "PUT", "path": "/admin/applications/{id}/resources", "credential": "access" }, { "method": "POST", "path": "/admin/applications/{id}/secret", "credential": "access" }, diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 37287855..86536b3d 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -333,6 +333,18 @@ The applications (clients) of the OpenID Provider service ([ADR 0001](./adr/0001 Only an HMAC of a shared secret is stored, so a lost secret is rotated, never recovered. +### Links + +A link lets a confidential application act for a public one, typically a backend acting for its desktop app or SPA ([ADR 0002](./adr/0002-native-and-desktop-apps.md), N4). It is the only way one application reaches users who signed in to another: a linked backend can send account notices (`POST /applications/account-notices` with `clientId`) to users of the application it acts for, named as that application. The API refuses a link unless the acting application is confidential, the linked one is a different public application, and both are first-party or both third-party (third-party links also need `third_party_clients_enabled`). Links do not chain. Writes are audited as `admin_oidc_application_link_added` and `admin_oidc_application_link_removed`, and every application response lists `links` (applications it acts for) and `linkedFrom` (applications acting for it). + +| Route | What it does | +| ------------------------------------------------ | ------------------------------------------------------ | +| `GET /admin/applications/:id/links` | The links of an application, in both directions. | +| `PUT /admin/applications/:id/links/:linkedId` | Let application `:id` act for `:linkedId`. Idempotent. | +| `DELETE /admin/applications/:id/links/:linkedId` | Remove that link. | + +Disabling either application, or moving one across the first-party boundary, ends a link's effect at once, and deleting either removes it. An application's `noticeActionOrigins` (bare `https` origins, never loopback) are where the action link of a notice it sends may point, besides its own `https` redirect origins. A backend with no redirect URIs, or one acting for a native app, sets them to its own site. + ### Resources and scopes A resource is an API that applications request access tokens for (RFC 8707). Its identifier, an absolute URI such as `https://api.example.com`, becomes the `aud` of those tokens and cannot change. It cannot be the API `ISSUER` or `oidc_issuer`. A resource declares its scopes, and each application is allowed a subset of them. The provider signs an access token for a resource only when the application is allowed that resource and every scope in the token. Writes are audited as `admin_oidc_resource_*` and `admin_oidc_application_resources_updated`. diff --git a/docs/adr/0001-openid-provider.md b/docs/adr/0001-openid-provider.md index 6cd49664..33198c79 100644 --- a/docs/adr/0001-openid-provider.md +++ b/docs/adr/0001-openid-provider.md @@ -311,14 +311,16 @@ The notices endpoint, `POST /applications/account-notices`, stays on the private **Phishing limits.** A notice is sent under the instance's trusted sender, so it is tightly constrained: -- It goes only to a user who already holds a grant with that application. +- It goes only to a user who already holds a grant with that application, or with an application it is linked to (amended 2026-11-02, below). - The application must have notices enabled by an admin (`oidc_clients`). - It is plain text only, inside a fixed template the application cannot change: "Sent by through ". It cannot pose as a Seamless security notice. -- An optional action link must be on one of the application's registered redirect origins. +- An optional action link must be on one of the application's `https` redirect origins or its `noticeActionOrigins`. - Rate limits apply per client and per user, and every notice writes an auth event. - Delivery uses `src/services/accountNotices.ts`, including external delivery (`account_notice_email`, `account_notice_sms`). - types#120 defines the request and response. The response lists channels, never addresses. +**Amendment, 2026-11-02: notices through a link (#547).** A user often signs in to a public client (a desktop app with a loopback redirect, or a SPA) whose server side is a separate confidential client. That backend holds no grants and the public client cannot authenticate, so neither could send the user a notice. An admin can now link a confidential client to the public clients it acts for, in `oidc_client_links` through `/admin/applications/:id/links`, with step-up and audit. Links stay within first-party or within third-party clients and do not chain ([ADR 0002](0002-native-and-desktop-apps.md), N4). The request gains an optional `clientId`: the calling client must hold a link to it, the grant check uses it, and the template names it, since that is the application the user knows. A missing link, or one whose clients are disabled or no longer meet the rules, answers the same `404 notice_unknown_user` as a missing grant. Everything else above still applies to the calling client: it needs `client_credentials`, `notices:send` and notices enabled, the action link is checked against its origins, and rate limits count against it. Clients also gain `noticeActionOrigins` (bare `https` origins, never loopback), so a client without `https` redirects can carry an action link. The auth event records the calling client as `clientId` and the client the notice was sent for as `sentForClientId` (ADR 0002, N5). + **Relation to #418.** The two are opposite directions on the same application record: - #516 is the application calling Seamless. It is a command, authorized by an OAuth scope. diff --git a/docs/adr/0002-native-and-desktop-apps.md b/docs/adr/0002-native-and-desktop-apps.md index 8910c54b..fffd9ad7 100644 --- a/docs/adr/0002-native-and-desktop-apps.md +++ b/docs/adr/0002-native-and-desktop-apps.md @@ -259,20 +259,20 @@ Seamless does not add per-app features for any of these. If two adopters need th ## Issue map -| Issue | State | Where | Must deliver | Governed by | -| ------------------------------ | ------ | ---------- | --------------------------------------------------------------------------------------------------- | ----------- | -| #508 | closed | API, edge | Shipped: native public clients, loopback and private-use redirects | N2 | -| #509 | closed | API, edge | Shipped: resource-bound JWT access tokens | N2, N3 | -| #511 | closed | edge | Shipped: introspection and revocation | N3, N9 | -| #510 | closed | API, edge | Shipped: step-up at the edge, `acr` and `amr` on ID tokens. Resource server half moves to oidc#34 | N7, N8 | -| #547 | open | API, types | `oidc_client_links`, link admin routes, `clientId` on notices, `noticeActionOrigins`, D14 amendment | N4, N5 | -| seamless-auth-oidc-provider#34 | open | edge | `acr`, `amr`, `auth_time` on resource access tokens and in introspection, none on device tokens | N7 | -| seamless-auth-oidc-provider#35 | open | edge, docs | The local recipe, seeding the three records and a link | N10 | -| seamless-auth-docs#137 | open | docs | The runnable desktop and backend example and the backend guide section | N11 | -| #418 | open | API | Links as the subscription scope rule | N6 | -| #512 | open | API, edge | DPoP, then the device key guidance in N9 | N9 | - -#547 also needs a types issue for the link shape, `clientId` and `noticeActionOrigins`, and console and CLI issues for managing links. They are opened when #547 starts. +| Issue | State | Where | Must deliver | Governed by | +| ------------------------------ | ------ | ---------- | ------------------------------------------------------------------------------------------------------------ | ----------- | +| #508 | closed | API, edge | Shipped: native public clients, loopback and private-use redirects | N2 | +| #509 | closed | API, edge | Shipped: resource-bound JWT access tokens | N2, N3 | +| #511 | closed | edge | Shipped: introspection and revocation | N3, N9 | +| #510 | closed | API, edge | Shipped: step-up at the edge, `acr` and `amr` on ID tokens. Resource server half moves to oidc#34 | N7, N8 | +| #547 | closed | API | Shipped: `oidc_client_links`, link admin routes, `clientId` on notices, `noticeActionOrigins`, D14 amendment | N4, N5 | +| seamless-auth-oidc-provider#34 | open | edge | `acr`, `amr`, `auth_time` on resource access tokens and in introspection, none on device tokens | N7 | +| seamless-auth-oidc-provider#35 | open | edge, docs | The local recipe, seeding the three records and a link | N10 | +| seamless-auth-docs#137 | open | docs | The runnable desktop and backend example and the backend guide section | N11 | +| #418 | open | API | Links as the subscription scope rule | N6 | +| #512 | open | API, edge | DPoP, then the device key guidance in N9 | N9 | + +#547's follow-ups are seamless-auth-types#148 for the link shape, `clientId` and `noticeActionOrigins` (the API extends the published schemas locally until then), seamless-auth-admin-dashboard#337 and seamless-cli#344 for managing links and `noticeActionOrigins`. ## Affected repos diff --git a/openapi.json b/openapi.json index 146a720c..739b45ab 100644 --- a/openapi.json +++ b/openapi.json @@ -5281,6 +5281,8 @@ "admin_oidc_application_created", "admin_oidc_application_deleted", "admin_oidc_application_keys_updated", + "admin_oidc_application_link_added", + "admin_oidc_application_link_removed", "admin_oidc_application_nonce_updated", "admin_oidc_application_pkce_updated", "admin_oidc_application_resources_updated", @@ -5404,6 +5406,8 @@ "admin_oidc_application_created", "admin_oidc_application_deleted", "admin_oidc_application_keys_updated", + "admin_oidc_application_link_added", + "admin_oidc_application_link_removed", "admin_oidc_application_nonce_updated", "admin_oidc_application_pkce_updated", "admin_oidc_application_resources_updated", @@ -7633,7 +7637,8 @@ "maxLength": 2000, "pattern": "^[^\\u0000-\\u0009\\u000b-\\u001f\\u007f]+$" }, - "actionUrl": { "type": "string", "maxLength": 2048, "format": "uri" } + "actionUrl": { "type": "string", "maxLength": 2048, "format": "uri" }, + "clientId": { "type": "string", "minLength": 1, "maxLength": 255 } }, "required": ["userId", "subject", "text"] } @@ -14079,7 +14084,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -14104,7 +14112,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] } } @@ -14144,7 +14155,7 @@ }, "post": { "summary": "Create an OpenID Provider application", - "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant.", + "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "requestBody": { @@ -14231,7 +14242,13 @@ }, "required": ["keys"] }, - "jwksUri": { "type": "string", "maxLength": 2048, "format": "uri" } + "jwksUri": { "type": "string", "maxLength": 2048, "format": "uri" }, + "noticeActionOrigins": { + "type": "array", + "items": { "type": "string" }, + "maxItems": 20, + "default": [] + } }, "required": ["name", "clientType"], "additionalProperties": false @@ -14268,7 +14285,10 @@ "refreshTokenTtlSeconds": 0, "disabled": true, "createdAt": "string", - "updatedAt": "string" + "updatedAt": "string", + "noticeActionOrigins": [null], + "links": [null], + "linkedFrom": [null] }, "clientSecret": "string" }, @@ -14339,7 +14359,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -14364,7 +14387,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] }, "clientSecret": { "type": "string" } @@ -14519,7 +14545,10 @@ "refreshTokenTtlSeconds": 0, "disabled": true, "createdAt": "string", - "updatedAt": "string" + "updatedAt": "string", + "noticeActionOrigins": [null], + "links": [null], + "linkedFrom": [null] } }, "schema": { @@ -14589,7 +14618,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -14614,7 +14646,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] } }, @@ -14776,7 +14811,12 @@ "minimum": 300, "maximum": 7776000 }, - "disabled": { "type": "boolean" } + "disabled": { "type": "boolean" }, + "noticeActionOrigins": { + "type": "array", + "items": { "type": "string" }, + "maxItems": 20 + } }, "additionalProperties": false } @@ -14812,7 +14852,10 @@ "refreshTokenTtlSeconds": 0, "disabled": true, "createdAt": "string", - "updatedAt": "string" + "updatedAt": "string", + "noticeActionOrigins": [null], + "links": [null], + "linkedFrom": [null] } }, "schema": { @@ -14882,7 +14925,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -14907,7 +14953,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] } }, @@ -15033,7 +15082,7 @@ }, "delete": { "summary": "Delete an OpenID Provider application", - "description": "Also deletes its resource grants and every provider grant, code and token issued to it.", + "description": "Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -15320,7 +15369,10 @@ "refreshTokenTtlSeconds": 0, "disabled": true, "createdAt": "string", - "updatedAt": "string" + "updatedAt": "string", + "noticeActionOrigins": [null], + "links": [null], + "linkedFrom": [null] }, "clientSecret": "string" }, @@ -15391,7 +15443,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -15416,7 +15471,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] }, "clientSecret": { "type": "string" } @@ -15632,7 +15690,10 @@ "refreshTokenTtlSeconds": 0, "disabled": true, "createdAt": "string", - "updatedAt": "string" + "updatedAt": "string", + "noticeActionOrigins": [null], + "links": [null], + "linkedFrom": [null] } }, "schema": { @@ -15702,7 +15763,10 @@ "refreshTokenTtlSeconds": { "type": "integer", "nullable": true }, "disabled": { "type": "boolean" }, "createdAt": { "type": "string" }, - "updatedAt": { "type": "string" } + "updatedAt": { "type": "string" }, + "noticeActionOrigins": { "type": "array", "items": { "type": "string" } }, + "links": { "type": "array", "items": { "type": "string" } }, + "linkedFrom": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", @@ -15727,7 +15791,10 @@ "refreshTokenTtlSeconds", "disabled", "createdAt", - "updatedAt" + "updatedAt", + "noticeActionOrigins", + "links", + "linkedFrom" ] } }, @@ -16165,6 +16232,562 @@ } } }, + "/admin/applications/{id}/links": { + "get": { + "summary": "List the links of an application", + "description": "`links` are the public applications this one acts for, and `linkedFrom` the confidential applications that act for this one.", + "tags": ["Admin", "OpenID Provider applications"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "links": [null], "linkedFrom": [null] }, + "schema": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "linkedClientId": { "type": "string" }, + "createdBy": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" } + }, + "required": ["clientId", "linkedClientId", "createdBy", "createdAt"] + } + }, + "linkedFrom": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "linkedClientId": { "type": "string" }, + "createdBy": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" } + }, + "required": ["clientId", "linkedClientId", "createdBy", "createdAt"] + } + } + }, + "required": ["links", "linkedFrom"] + } + } + } + }, + "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"] + } + } + } + }, + "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"] + } + } + } + } + } + } + }, + "/admin/applications/{id}/links/{linkedId}": { + "put": { + "summary": "Let a confidential application act for a public one", + "description": "A linked backend can send account notices to users of the application it acts for, named as that application. The acting application must be confidential, the linked one a different public application, and both first-party or both third-party. Links do not chain. Idempotent.", + "tags": ["Admin", "OpenID Provider applications"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, + "required": true, + "name": "linkedId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "link": { + "clientId": "string", + "linkedClientId": "string", + "createdBy": "string", + "createdAt": "string" + } + }, + "schema": { + "type": "object", + "properties": { + "link": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "linkedClientId": { "type": "string" }, + "createdBy": { "type": "string", "nullable": true }, + "createdAt": { "type": "string" } + }, + "required": ["clientId", "linkedClientId", "createdBy", "createdAt"] + } + }, + "required": ["link"] + } + } + } + }, + "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"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "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"] + } + } + } + } + } + }, + "delete": { + "summary": "Remove a link between applications", + "tags": ["Admin", "OpenID Provider applications"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { "type": "string", "minLength": 1, "maxLength": 255 }, + "required": true, + "name": "linkedId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed", + "application_notice" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" }, + "application": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["clientId", "name"] + } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed", + "application_notice" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" }, + "application": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["clientId", "name"] + } + }, + "required": ["kind", "notice", "to", "text"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "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"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "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"] + } + } + } + } + } + } + }, "/admin/resources": { "get": { "summary": "List OpenID Provider resources", diff --git a/src/controllers/applicationNotices.ts b/src/controllers/applicationNotices.ts index c27875d4..3646d450 100644 --- a/src/controllers/applicationNotices.ts +++ b/src/controllers/applicationNotices.ts @@ -4,12 +4,12 @@ * See LICENSE file in the project root for full license information */ -import type { SendApplicationNoticeRequest } from '@seamless-auth/types'; import { Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { acceptedDeliveryKinds, canReturnExternalDelivery } from '../lib/externalDelivery.js'; import type { ApplicationRequest } from '../middleware/requireApplicationToken.js'; +import type { SendApplicationNoticeRequest } from '../schemas/applicationNotices.requests.js'; import { ownerWasNotified } from '../services/accountNotices.js'; import { ApplicationNoticeError, @@ -35,7 +35,11 @@ export async function sendApplicationNoticeHandler(req: ApplicationRequest, res: await AuthEventService.log({ type: 'oidc_account_notice_failed', req, - metadata: { clientId: client.id, reason: error.code }, + metadata: { + clientId: client.id, + ...(body.clientId ? { sentForClientId: body.clientId } : {}), + reason: error.code, + }, }); return res.status(error.status).json({ error: error.code }); } @@ -46,7 +50,11 @@ export async function sendApplicationNoticeHandler(req: ApplicationRequest, res: status, })); - const audit = { userId: sent.userId, req, metadata: { clientId: client.id, channels } }; + const audit = { + userId: sent.userId, + req, + metadata: { clientId: client.id, sentForClientId: sent.sentFor.id, channels }, + }; if (ownerWasNotified(outcome)) { await AuthEventService.log({ ...audit, type: 'oidc_account_notice_sent' }); diff --git a/src/controllers/oidcApplications.ts b/src/controllers/oidcApplications.ts index b82a05a2..0fdd7a08 100644 --- a/src/controllers/oidcApplications.ts +++ b/src/controllers/oidcApplications.ts @@ -17,11 +17,17 @@ import { listOidcApplications, OidcApplicationError, OidcApplicationInvalidError, + presentOidcApplication, + presentOidcApplications, rotateOidcApplicationSecret, - serializeOidcApplication, setOidcApplicationKeys, updateOidcApplication, } from '../services/oidcApplicationService.js'; +import { + addOidcApplicationLink, + listOidcApplicationLinks, + removeOidcApplicationLink, +} from '../services/oidcClientLinkService.js'; import { AuthenticatedRequest } from '../types/types.js'; function audit(req: Request, event: { type: AuthEventType; metadata: Record }) { @@ -50,13 +56,13 @@ function keysSummary(keys: OidcApplicationKeysRequest) { export async function listApplications(_req: Request, res: Response) { const clients = await listOidcApplications(); - return res.json({ applications: clients.map(serializeOidcApplication) }); + return res.json({ applications: await presentOidcApplications(clients) }); } export async function getApplication(req: Request, res: Response) { try { const client = await findOidcApplication(req.params.id as string); - return res.json({ application: serializeOidcApplication(client) }); + return res.json({ application: await presentOidcApplication(client) }); } catch (error) { return sendError(res, error); } @@ -65,7 +71,7 @@ export async function getApplication(req: Request, res: Response) { export async function createApplication(req: Request, res: Response) { try { const { client, clientSecret } = await createOidcApplication(req.body); - const application = serializeOidcApplication(client); + const application = await presentOidcApplication(client); await audit(req, { type: 'admin_oidc_application_created', @@ -79,6 +85,7 @@ export async function createApplication(req: Request, res: Response) { pkceRequired: application.pkceRequired, nonceRequired: application.nonceRequired, redirectUris: application.redirectUris, + noticeActionOrigins: application.noticeActionOrigins, }, }); @@ -130,7 +137,7 @@ export async function updateApplication(req: Request, res: Response) { }, }); - return res.json({ application: serializeOidcApplication(client) }); + return res.json({ application: await presentOidcApplication(client) }); } catch (error) { return sendError(res, error); } @@ -148,7 +155,7 @@ export async function putApplicationKeys(req: Request, res: Response) { }, }); - return res.json({ application: serializeOidcApplication(client) }); + return res.json({ application: await presentOidcApplication(client) }); } catch (error) { return sendError(res, error); } @@ -163,7 +170,7 @@ export async function rotateApplicationSecret(req: Request, res: Response) { metadata: { clientId: client.id }, }); - return res.json({ application: serializeOidcApplication(client), clientSecret }); + return res.json({ application: await presentOidcApplication(client), clientSecret }); } catch (error) { return sendError(res, error); } @@ -181,3 +188,51 @@ export async function deleteApplication(req: Request, res: Response) { return sendError(res, error); } } + +export async function getApplicationLinks(req: Request, res: Response) { + try { + return res.json(await listOidcApplicationLinks(req.params.id as string)); + } catch (error) { + return sendError(res, error); + } +} + +export async function putApplicationLink(req: Request, res: Response) { + try { + const clientId = req.params.id as string; + const linkedClientId = req.params.linkedId as string; + const { link, created } = await addOidcApplicationLink( + clientId, + linkedClientId, + (req as AuthenticatedRequest).user?.id ?? null, + ); + + if (created) { + await audit(req, { + type: 'admin_oidc_application_link_added', + metadata: { clientId, linkedClientId }, + }); + } + + return res.json({ link }); + } catch (error) { + return sendError(res, error); + } +} + +export async function deleteApplicationLink(req: Request, res: Response) { + try { + const clientId = req.params.id as string; + const linkedClientId = req.params.linkedId as string; + await removeOidcApplicationLink(clientId, linkedClientId); + + await audit(req, { + type: 'admin_oidc_application_link_removed', + metadata: { clientId, linkedClientId }, + }); + + return res.json({ message: 'Link removed' }); + } catch (error) { + return sendError(res, error); + } +} diff --git a/src/generated/api.ts b/src/generated/api.ts index c395fa42..1d71b917 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4970,6 +4970,8 @@ export interface paths { | 'admin_oidc_application_created' | 'admin_oidc_application_deleted' | 'admin_oidc_application_keys_updated' + | 'admin_oidc_application_link_added' + | 'admin_oidc_application_link_removed' | 'admin_oidc_application_nonce_updated' | 'admin_oidc_application_pkce_updated' | 'admin_oidc_application_resources_updated' @@ -5087,6 +5089,8 @@ export interface paths { | 'admin_oidc_application_created' | 'admin_oidc_application_deleted' | 'admin_oidc_application_keys_updated' + | 'admin_oidc_application_link_added' + | 'admin_oidc_application_link_removed' | 'admin_oidc_application_nonce_updated' | 'admin_oidc_application_pkce_updated' | 'admin_oidc_application_resources_updated' @@ -7290,6 +7294,7 @@ export interface paths { text: string; /** Format: uri */ actionUrl?: string; + clientId?: string; }; }; }; @@ -14020,6 +14025,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }[]; }; }; @@ -14065,7 +14073,7 @@ export interface paths { put?: never; /** * Create an OpenID Provider application - * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. + * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. */ post: { parameters: { @@ -14122,6 +14130,8 @@ export interface paths { }; /** Format: uri */ jwksUri?: string; + /** @default [] */ + noticeActionOrigins?: string[]; }; }; }; @@ -14167,7 +14177,16 @@ export interface paths { * "refreshTokenTtlSeconds": 0, * "disabled": true, * "createdAt": "string", - * "updatedAt": "string" + * "updatedAt": "string", + * "noticeActionOrigins": [ + * null + * ], + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] * }, * "clientSecret": "string" * } @@ -14211,6 +14230,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }; clientSecret?: string; }; @@ -14379,7 +14401,16 @@ export interface paths { * "refreshTokenTtlSeconds": 0, * "disabled": true, * "createdAt": "string", - * "updatedAt": "string" + * "updatedAt": "string", + * "noticeActionOrigins": [ + * null + * ], + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] * } * } */ @@ -14422,6 +14453,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }; }; }; @@ -14516,7 +14550,7 @@ export interface paths { post?: never; /** * Delete an OpenID Provider application - * @description Also deletes its resource grants and every provider grant, code and token issued to it. + * @description Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it. */ delete: { parameters: { @@ -14766,6 +14800,7 @@ export interface paths { idTokenTtlSeconds?: number | null; refreshTokenTtlSeconds?: number | null; disabled?: boolean; + noticeActionOrigins?: string[]; }; }; }; @@ -14811,7 +14846,16 @@ export interface paths { * "refreshTokenTtlSeconds": 0, * "disabled": true, * "createdAt": "string", - * "updatedAt": "string" + * "updatedAt": "string", + * "noticeActionOrigins": [ + * null + * ], + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] * } * } */ @@ -14854,6 +14898,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }; }; }; @@ -15040,7 +15087,16 @@ export interface paths { * "refreshTokenTtlSeconds": 0, * "disabled": true, * "createdAt": "string", - * "updatedAt": "string" + * "updatedAt": "string", + * "noticeActionOrigins": [ + * null + * ], + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] * }, * "clientSecret": "string" * } @@ -15084,6 +15140,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }; clientSecret: string; }; @@ -15295,7 +15354,16 @@ export interface paths { * "refreshTokenTtlSeconds": 0, * "disabled": true, * "createdAt": "string", - * "updatedAt": "string" + * "updatedAt": "string", + * "noticeActionOrigins": [ + * null + * ], + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] * } * } */ @@ -15338,6 +15406,9 @@ export interface paths { disabled: boolean; createdAt: string; updatedAt: string; + noticeActionOrigins: string[]; + links: string[]; + linkedFrom: string[]; }; }; }; @@ -15764,6 +15835,539 @@ export interface paths { patch?: never; trace?: never; }; + '/admin/applications/{id}/links': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the links of an application + * @description `links` are the public applications this one acts for, and `linkedFrom` the confidential applications that act for this one. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "links": [ + * null + * ], + * "linkedFrom": [ + * null + * ] + * } + */ + 'application/json': { + links: { + clientId: string; + linkedClientId: string; + createdBy: string | null; + createdAt: string; + }[]; + linkedFrom: { + clientId: string; + linkedClientId: string; + createdBy: string | null; + createdAt: string; + }[]; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/admin/applications/{id}/links/{linkedId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Let a confidential application act for a public one + * @description A linked backend can send account notices to users of the application it acts for, named as that application. The acting application must be confidential, the linked one a different public application, and both first-party or both third-party. Links do not chain. Idempotent. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + linkedId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "link": { + * "clientId": "string", + * "linkedClientId": "string", + * "createdBy": "string", + * "createdAt": "string" + * } + * } + */ + 'application/json': { + link: { + clientId: string; + linkedClientId: string; + createdBy: string | null; + createdAt: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + post?: never; + /** Remove a link between applications */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + linkedId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/admin/resources': { parameters: { query?: never; diff --git a/src/migrations/20261102120000-add-oidc-client-links.cjs b/src/migrations/20261102120000-add-oidc-client-links.cjs new file mode 100644 index 00000000..3a792f25 --- /dev/null +++ b/src/migrations/20261102120000-add-oidc-client-links.cjs @@ -0,0 +1,47 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * A link lets a confidential client act for a public client of the same adopter, so a + * backend can send account notices to users of its desktop app (ADR 0002, N4 and N5). + * `noticeActionOrigins` gives a client without https redirects somewhere an action link + * may point. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.oidc_clients + ADD COLUMN IF NOT EXISTS "noticeActionOrigins" jsonb DEFAULT '[]'::jsonb NOT NULL; + + CREATE TABLE IF NOT EXISTS public.oidc_client_links ( + "clientId" character varying(255) NOT NULL, + "linkedClientId" character varying(255) NOT NULL, + "createdBy" uuid, + "createdAt" timestamp with time zone NOT NULL, + CONSTRAINT oidc_client_links_pkey PRIMARY KEY ("clientId", "linkedClientId"), + CONSTRAINT oidc_client_links_client_fkey + FOREIGN KEY ("clientId") REFERENCES public.oidc_clients(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT oidc_client_links_linked_client_fkey + FOREIGN KEY ("linkedClientId") REFERENCES public.oidc_clients(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT oidc_client_links_distinct CHECK ("clientId" <> "linkedClientId") + ); + + CREATE INDEX IF NOT EXISTS oidc_client_links_linked_client_idx + ON public.oidc_client_links ("linkedClientId"); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS public.oidc_client_links; + ALTER TABLE public.oidc_clients DROP COLUMN IF EXISTS "noticeActionOrigins"; + `); + }, +}; diff --git a/src/models/oidcClientLinks.ts b/src/models/oidcClientLinks.ts new file mode 100644 index 00000000..84a58504 --- /dev/null +++ b/src/models/oidcClientLinks.ts @@ -0,0 +1,47 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +/** A confidential client (`clientId`) acting for a public client (`linkedClientId`). */ +export interface OidcClientLinkAttributes { + clientId: string; + linkedClientId: string; + /** The admin who made the link. */ + createdBy?: string | null; + createdAt?: Date; +} + +type OidcClientLinkCreationAttributes = Optional; + +export class OidcClientLink + extends Model + implements OidcClientLinkAttributes +{ + declare clientId: string; + declare linkedClientId: string; + declare createdBy: string | null; + declare readonly createdAt: Date; +} + +const initializeOidcClientLinkModel = (sequelize: Sequelize) => { + OidcClientLink.init( + { + clientId: { type: DataTypes.STRING(255), primaryKey: true }, + linkedClientId: { type: DataTypes.STRING(255), primaryKey: true }, + createdBy: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + tableName: 'oidc_client_links', + modelName: 'OidcClientLink', + updatedAt: false, + }, + ); + return OidcClientLink; +}; + +export default initializeOidcClientLinkModel; diff --git a/src/models/oidcClients.ts b/src/models/oidcClients.ts index bbfbd8eb..295ee41f 100644 --- a/src/models/oidcClients.ts +++ b/src/models/oidcClients.ts @@ -30,6 +30,8 @@ export interface OidcClientAttributes { refreshTokenTtlSeconds?: number | null; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** https origins an account notice's action link may point at, besides https redirects. */ + noticeActionOrigins: string[]; pkceRequired: boolean; nonceRequired: boolean; disabledAt?: Date | null; @@ -52,6 +54,7 @@ type OidcClientCreationAttributes = Optional< | 'refreshTokenTtlSeconds' | 'deviceFlowEnabled' | 'noticesEnabled' + | 'noticeActionOrigins' | 'pkceRequired' | 'nonceRequired' | 'disabledAt' @@ -78,6 +81,7 @@ export class OidcClient declare refreshTokenTtlSeconds: number | null; declare deviceFlowEnabled: boolean; declare noticesEnabled: boolean; + declare noticeActionOrigins: string[]; declare pkceRequired: boolean; declare nonceRequired: boolean; declare disabledAt: Date | null; @@ -109,6 +113,7 @@ const initializeOidcClientModel = (sequelize: Sequelize) => { refreshTokenTtlSeconds: { type: DataTypes.INTEGER, allowNull: true }, deviceFlowEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, noticesEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + noticeActionOrigins: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, pkceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, nonceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, disabledAt: { type: DataTypes.DATE, allowNull: true }, diff --git a/src/routes/oidcApplications.routes.ts b/src/routes/oidcApplications.routes.ts index e8db6437..151e9443 100644 --- a/src/routes/oidcApplications.routes.ts +++ b/src/routes/oidcApplications.routes.ts @@ -5,24 +5,21 @@ */ import { - OidcApplicationCreatedResponseSchema, - OidcApplicationCreateRequestSchema, OidcApplicationIdParamsSchema, OidcApplicationKeysRequestSchema, - OidcApplicationListResponseSchema, OidcApplicationResourcesRequestSchema, OidcApplicationResourcesResponseSchema, - OidcApplicationResponseSchema, - OidcApplicationSecretResponseSchema, - OidcApplicationUpdateRequestSchema, } from '@seamless-auth/types'; import { createApplication, deleteApplication, + deleteApplicationLink, getApplication, + getApplicationLinks, listApplications, putApplicationKeys, + putApplicationLink, rotateApplicationSecret, updateApplication, } from '../controllers/oidcApplications.js'; @@ -31,6 +28,19 @@ import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { ErrorSchema, MessageSchema, ValidationErrorSchema } from '../schemas/generic.responses.js'; +import { + OidcApplicationCreateRequestSchema, + OidcApplicationLinkParamsSchema, + OidcApplicationUpdateRequestSchema, +} from '../schemas/oidcApplications.requests.js'; +import { + OidcApplicationCreatedResponseSchema, + OidcApplicationLinkResponseSchema, + OidcApplicationLinksResponseSchema, + OidcApplicationListResponseSchema, + OidcApplicationResponseSchema, + OidcApplicationSecretResponseSchema, +} from '../schemas/oidcApplications.responses.js'; import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; /** @@ -59,7 +69,7 @@ router.post( ...write, summary: 'Create an OpenID Provider application', description: - 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant.', + 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.', schemas: { body: OidcApplicationCreateRequestSchema, response: { @@ -112,7 +122,7 @@ router.delete( ...write, summary: 'Delete an OpenID Provider application', description: - 'Also deletes its resource grants and every provider grant, code and token issued to it.', + 'Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.', schemas: { params: OidcApplicationIdParamsSchema, response: { 200: MessageSchema, 403: StepUpRequiredErrorSchema, 404: ErrorSchema }, @@ -196,4 +206,52 @@ router.put( putApplicationResources, ); +router.get( + '/:id/links', + { + ...read, + summary: 'List the links of an application', + description: + '`links` are the public applications this one acts for, and `linkedFrom` the confidential applications that act for this one.', + schemas: { + params: OidcApplicationIdParamsSchema, + response: { 200: OidcApplicationLinksResponseSchema, 404: ErrorSchema }, + }, + }, + getApplicationLinks, +); + +router.put( + '/:id/links/:linkedId', + { + ...write, + summary: 'Let a confidential application act for a public one', + description: + 'A linked backend can send account notices to users of the application it acts for, named as that application. The acting application must be confidential, the linked one a different public application, and both first-party or both third-party. Links do not chain. Idempotent.', + schemas: { + params: OidcApplicationLinkParamsSchema, + response: { + 200: OidcApplicationLinkResponseSchema, + 400: ErrorSchema, + 403: StepUpRequiredErrorSchema, + 404: ErrorSchema, + }, + }, + }, + putApplicationLink, +); + +router.delete( + '/:id/links/:linkedId', + { + ...write, + summary: 'Remove a link between applications', + schemas: { + params: OidcApplicationLinkParamsSchema, + response: { 200: MessageSchema, 403: StepUpRequiredErrorSchema, 404: ErrorSchema }, + }, + }, + deleteApplicationLink, +); + export default router.router; diff --git a/src/schemas/applicationNotices.requests.ts b/src/schemas/applicationNotices.requests.ts index 0c8e8030..3d8730dd 100644 --- a/src/schemas/applicationNotices.requests.ts +++ b/src/schemas/applicationNotices.requests.ts @@ -4,4 +4,13 @@ * See LICENSE file in the project root for full license information */ -export { SendApplicationNoticeRequestSchema } from '@seamless-auth/types'; +import { SendApplicationNoticeRequestSchema as BaseRequestSchema } from '@seamless-auth/types'; +import { z } from 'zod'; + +// TODO(seamless-auth-types#148): move `clientId` to @seamless-auth/types. +export const SendApplicationNoticeRequestSchema = BaseRequestSchema.extend({ + /** A client the caller is linked to, to send for. The caller itself when omitted. */ + clientId: z.string().min(1).max(255).optional(), +}); + +export type SendApplicationNoticeRequest = z.infer; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index a0382807..7c072667 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -22,6 +22,8 @@ export const AUTH_EVENT_TYPES = [ 'admin_oidc_application_created', 'admin_oidc_application_deleted', 'admin_oidc_application_keys_updated', + 'admin_oidc_application_link_added', + 'admin_oidc_application_link_removed', 'admin_oidc_application_nonce_updated', 'admin_oidc_application_pkce_updated', 'admin_oidc_application_resources_updated', diff --git a/src/schemas/oidcApplications.requests.ts b/src/schemas/oidcApplications.requests.ts new file mode 100644 index 00000000..132e03b0 --- /dev/null +++ b/src/schemas/oidcApplications.requests.ts @@ -0,0 +1,58 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + OidcApplicationCreateRequestSchema as BaseCreateRequestSchema, + OidcApplicationIdParamsSchema, + OidcApplicationUpdateRequestSchema as BaseUpdateRequestSchema, +} from '@seamless-auth/types'; +import { z } from 'zod'; + +// TODO(seamless-auth-types#148): move these to @seamless-auth/types. + +const LOOPBACK_HOST = /^(127(\.\d{1,3}){3}|\[::1\]|localhost|.+\.localhost)$/i; + +export function noticeActionOriginProblem(value: string): string | null { + let url: URL; + try { + url = new URL(value); + } catch { + return 'notice action origin must be an absolute https origin'; + } + if (url.protocol !== 'https:') return 'notice action origins must be https'; + if (LOOPBACK_HOST.test(url.hostname)) return 'notice action origins cannot be loopback'; + if (url.origin !== value) { + return 'notice action origins are bare origins, such as https://app.example.com'; + } + return null; +} + +export const NoticeActionOriginsSchema = z + .array( + z.string().superRefine((value, ctx) => { + const problem = noticeActionOriginProblem(value); + if (problem) ctx.addIssue({ code: 'custom', message: problem }); + }), + ) + .max(20) + .refine((origins) => new Set(origins).size === origins.length, { + message: 'Origins must be unique', + }); + +export const OidcApplicationCreateRequestSchema = BaseCreateRequestSchema.safeExtend({ + noticeActionOrigins: NoticeActionOriginsSchema.default([]), +}); + +export const OidcApplicationUpdateRequestSchema = BaseUpdateRequestSchema.extend({ + noticeActionOrigins: NoticeActionOriginsSchema.optional(), +}); + +export type OidcApplicationCreateRequest = z.infer; +export type OidcApplicationUpdateRequest = z.infer; + +export const OidcApplicationLinkParamsSchema = OidcApplicationIdParamsSchema.extend({ + linkedId: z.string().min(1).max(255), +}); diff --git a/src/schemas/oidcApplications.responses.ts b/src/schemas/oidcApplications.responses.ts new file mode 100644 index 00000000..dc0dc9ad --- /dev/null +++ b/src/schemas/oidcApplications.responses.ts @@ -0,0 +1,51 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { OidcApplicationSchema as BaseApplicationSchema } from '@seamless-auth/types'; +import { z } from 'zod'; + +// TODO(seamless-auth-types#148): move these to @seamless-auth/types. + +export const OidcApplicationSchema = BaseApplicationSchema.extend({ + noticeActionOrigins: z.array(z.string()), + /** Clients this one acts for. */ + links: z.array(z.string()), + /** Clients that act for this one. */ + linkedFrom: z.array(z.string()), +}); + +export type OidcApplicationView = z.infer; + +export const OidcApplicationResponseSchema = z.object({ application: OidcApplicationSchema }); + +export const OidcApplicationCreatedResponseSchema = OidcApplicationResponseSchema.extend({ + clientSecret: z.string().optional(), +}); + +export const OidcApplicationListResponseSchema = z.object({ + applications: z.array(OidcApplicationSchema), +}); + +export const OidcApplicationSecretResponseSchema = z.object({ + application: OidcApplicationSchema, + clientSecret: z.string(), +}); + +export const OidcApplicationLinkSchema = z.object({ + /** The confidential client that acts. */ + clientId: z.string(), + /** The public client it acts for. */ + linkedClientId: z.string(), + createdBy: z.string().nullable(), + createdAt: z.string(), +}); + +export const OidcApplicationLinksResponseSchema = z.object({ + links: z.array(OidcApplicationLinkSchema), + linkedFrom: z.array(OidcApplicationLinkSchema), +}); + +export const OidcApplicationLinkResponseSchema = z.object({ link: OidcApplicationLinkSchema }); diff --git a/src/services/applicationNoticeService.ts b/src/services/applicationNoticeService.ts index 2185e466..b6a9a270 100644 --- a/src/services/applicationNoticeService.ts +++ b/src/services/applicationNoticeService.ts @@ -4,16 +4,18 @@ * See LICENSE file in the project root for full license information */ -import type { MessagingChannel, SendApplicationNoticeRequest } from '@seamless-auth/types'; +import type { MessagingChannel } from '@seamless-auth/types'; import type { OidcClient } from '../models/oidcClients.js'; import { User } from '../models/users.js'; +import type { SendApplicationNoticeRequest } from '../schemas/applicationNotices.requests.js'; import { type AccountNotice, type AccountNoticeOutcome, sendAccountNotice, verifiedAddresses, } from './accountNotices.js'; +import { findLinkedOidcClient } from './oidcClientLinkService.js'; import { hasOidcGrantForUser } from './oidcPayloadStore.js'; export class ApplicationNoticeError extends Error { @@ -30,14 +32,18 @@ function singleLine(value: string) { return value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); } -/** Only the https origins the client registered redirects on: never loopback or private-use. */ +/** + * The client's notice action origins and the https origins it registered redirects on: + * never loopback or private-use. + */ function registeredOrigins(client: OidcClient) { - return new Set( - client.redirectUris.flatMap((uri) => { + return new Set([ + ...client.noticeActionOrigins, + ...client.redirectUris.flatMap((uri) => { const url = new URL(uri); return url.protocol === 'https:' ? [url.origin] : []; }), - ); + ]); } export function actionUrlAllowed(client: OidcClient, actionUrl: string) { @@ -76,22 +82,31 @@ export function applicationNotice( } /** - * Sends a notice on the client's behalf to a user who holds a grant with it. A user that - * does not exist and one with no grant answer the same, so a client cannot probe for - * accounts it has no relationship with. + * Sends a notice on the client's behalf to a user who holds a grant with it, or with the + * client named in `clientId` that it is linked to (ADR 0002, N5). A user that does not + * exist, one with no grant and a client the caller is not linked to all answer the same, + * so a client cannot probe for accounts or clients it has no relationship with. + * + * The action link is checked against the caller's origins, since it is the caller's site + * the user lands on; the template names the client the user signed in to. */ export async function sendApplicationNotice( client: OidcClient, instanceName: string, request: SendApplicationNoticeRequest, delivery: { external: boolean; returnable?: ReadonlySet }, -): Promise<{ userId: string; outcome: AccountNoticeOutcome }> { +): Promise<{ userId: string; sentFor: OidcClient; outcome: AccountNoticeOutcome }> { if (request.actionUrl && !actionUrlAllowed(client, request.actionUrl)) { throw new ApplicationNoticeError(400, 'notice_action_url_not_allowed'); } + const sentFor = + request.clientId && request.clientId !== client.id + ? await findLinkedOidcClient(client, request.clientId) + : client; + const user = await User.findByPk(request.userId); - if (!user || user.revoked || !(await hasOidcGrantForUser(user.id, client.id))) { + if (!sentFor || !user || user.revoked || !(await hasOidcGrantForUser(user.id, sentFor.id))) { throw new ApplicationNoticeError(404, 'notice_unknown_user'); } @@ -110,9 +125,9 @@ export async function sendApplicationNotice( const outcome = await sendAccountNotice( recipient, - applicationNotice(client, instanceName, request), + applicationNotice(sentFor, instanceName, request), delivery, ); - return { userId: user.id, outcome }; + return { userId: user.id, sentFor, outcome }; } diff --git a/src/services/oidcApplicationService.ts b/src/services/oidcApplicationService.ts index 1bd37a5c..11f9fcde 100644 --- a/src/services/oidcApplicationService.ts +++ b/src/services/oidcApplicationService.ts @@ -6,20 +6,25 @@ import { type OidcApplication, - type OidcApplicationCreateRequest, OidcApplicationCreateRequestSchema, type OidcApplicationGrantType, OidcApplicationGrantTypeSchema, type OidcApplicationKeysRequest, - type OidcApplicationUpdateRequest, } from '@seamless-auth/types'; import { randomBytes } from 'crypto'; +import { Op } from 'sequelize'; import type { ZodError } from 'zod'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { OidcClientLink } from '../models/oidcClientLinks.js'; import { OidcClientResource } from '../models/oidcClientResources.js'; import { OidcClient, type OidcClientAuthMethod } from '../models/oidcClients.js'; import { OidcPayload } from '../models/oidcPayloads.js'; +import type { + OidcApplicationCreateRequest, + OidcApplicationUpdateRequest, +} from '../schemas/oidcApplications.requests.js'; +import type { OidcApplicationView } from '../schemas/oidcApplications.responses.js'; import { createOidcClientSecretLookup, isNativeRedirectUri } from './oidcClientRegistry.js'; export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; @@ -76,6 +81,33 @@ export function serializeOidcApplication(client: OidcClient): OidcApplication { }; } +/** The application as the admin routes return it, with its links in both directions. */ +export async function presentOidcApplications( + clients: OidcClient[], +): Promise { + const ids = clients.map((client) => client.id); + const links = ids.length + ? await OidcClientLink.findAll({ + where: { [Op.or]: [{ clientId: ids }, { linkedClientId: ids }] }, + order: [['createdAt', 'ASC']], + }) + : []; + + return clients.map((client) => ({ + ...serializeOidcApplication(client), + noticeActionOrigins: client.noticeActionOrigins, + links: links.filter((link) => link.clientId === client.id).map((link) => link.linkedClientId), + linkedFrom: links + .filter((link) => link.linkedClientId === client.id) + .map((link) => link.clientId), + })); +} + +export async function presentOidcApplication(client: OidcClient) { + const [application] = await presentOidcApplications([client]); + return application; +} + function storedGrantTypes(grants: string[], deviceFlowEnabled: boolean) { return deviceFlowEnabled ? [...grants, DEVICE_CODE_GRANT] : grants; } @@ -153,6 +185,7 @@ export async function createOidcApplication(input: OidcApplicationCreateRequest) refreshTokenTtlSeconds: input.refreshTokenTtlSeconds ?? null, deviceFlowEnabled: input.deviceFlowEnabled, noticesEnabled: input.noticesEnabled, + noticeActionOrigins: input.noticeActionOrigins, pkceRequired: input.pkceRequired, nonceRequired: input.nonceRequired, }); @@ -167,7 +200,7 @@ export async function createOidcApplication(input: OidcApplicationCreateRequest) */ export async function updateOidcApplication(id: string, patch: OidcApplicationUpdateRequest) { const client = await findOidcApplication(id); - const { disabled, ...settings } = patch; + const { disabled, noticeActionOrigins, ...settings } = patch; await assertInstanceAllows(settings); @@ -216,6 +249,7 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp accessTokenTtlSeconds: next.accessTokenTtlSeconds ?? null, idTokenTtlSeconds: next.idTokenTtlSeconds ?? null, refreshTokenTtlSeconds: next.refreshTokenTtlSeconds ?? null, + ...(noticeActionOrigins ? { noticeActionOrigins } : {}), ...(keepsKeys ? {} : { jwks: null, jwksUri: null }), ...(SHARED_SECRET_METHODS.has(method) ? {} : { secretLookup: null }), ...(disabled === undefined @@ -260,8 +294,9 @@ export async function rotateOidcApplicationSecret(id: string) { } /** - * Deletes the application with its resource grants and every provider record issued to - * it (grants, codes and tokens), so nothing it was given outlives it. + * Deletes the application with its resource grants, its links in both directions and + * every provider record issued to it (grants, codes and tokens), so nothing it was given + * outlives it. */ export async function deleteOidcApplication(id: string) { const client = await findOidcApplication(id); @@ -269,6 +304,10 @@ export async function deleteOidcApplication(id: string) { await OidcClient.sequelize!.transaction(async (transaction) => { await OidcPayload.destroy({ where: { payload: { clientId: id } }, transaction }); await OidcClientResource.destroy({ where: { clientId: id }, transaction }); + await OidcClientLink.destroy({ + where: { [Op.or]: [{ clientId: id }, { linkedClientId: id }] }, + transaction, + }); await client.destroy({ transaction }); }); } diff --git a/src/services/oidcClientLinkService.ts b/src/services/oidcClientLinkService.ts new file mode 100644 index 00000000..24610c76 --- /dev/null +++ b/src/services/oidcClientLinkService.ts @@ -0,0 +1,95 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { OidcClientLink } from '../models/oidcClientLinks.js'; +import type { OidcClient } from '../models/oidcClients.js'; +import { findOidcApplication, OidcApplicationError } from './oidcApplicationService.js'; +import { findActiveOidcClient } from './oidcClientRegistry.js'; + +export function serializeOidcClientLink(link: OidcClientLink) { + return { + clientId: link.clientId, + linkedClientId: link.linkedClientId, + createdBy: link.createdBy ?? null, + createdAt: new Date(link.createdAt).toISOString(), + }; +} + +/** + * ADR 0002, N4. Checked when a link is made and again on every use, so a client whose + * party changes after it was linked stops reaching the other's users at once. + */ +async function linkProblem(client: OidcClient, linked: OidcClient): Promise { + if (client.clientType !== 'confidential') { + return 'Only a confidential application can act for another'; + } + if (linked.id === client.id) return 'An application cannot be linked to itself'; + if (linked.clientType !== 'public') { + return 'Only a public application can be acted for; a confidential one sends its own notices'; + } + if (client.firstParty !== linked.firstParty) { + return 'Links stay within first-party or within third-party applications'; + } + if (!client.firstParty && !(await getSystemConfig()).third_party_clients_enabled) { + return 'Third-party clients are off on this instance (third_party_clients_enabled)'; + } + return null; +} + +export async function listOidcApplicationLinks(id: string) { + await findOidcApplication(id); + + const [links, linkedFrom] = await Promise.all([ + OidcClientLink.findAll({ where: { clientId: id }, order: [['createdAt', 'ASC']] }), + OidcClientLink.findAll({ where: { linkedClientId: id }, order: [['createdAt', 'ASC']] }), + ]); + + return { + links: links.map(serializeOidcClientLink), + linkedFrom: linkedFrom.map(serializeOidcClientLink), + }; +} + +/** Idempotent: `created` is false when the link already existed. */ +export async function addOidcApplicationLink(id: string, linkedId: string, adminId: string | null) { + const client = await findOidcApplication(id); + const linked = await findOidcApplication(linkedId); + + const problem = await linkProblem(client, linked); + if (problem) throw new OidcApplicationError(400, problem); + + const [link, created] = await OidcClientLink.findOrCreate({ + where: { clientId: id, linkedClientId: linkedId }, + defaults: { clientId: id, linkedClientId: linkedId, createdBy: adminId }, + }); + + return { link: serializeOidcClientLink(link), created }; +} + +export async function removeOidcApplicationLink(id: string, linkedId: string) { + const removed = await OidcClientLink.destroy({ + where: { clientId: id, linkedClientId: linkedId }, + }); + if (!removed) throw new OidcApplicationError(404, 'Link not found'); +} + +/** + * The active client `client` may act for, or null when there is no link, the linked + * client is disabled or gone, or the link no longer meets the rules. Every null answers + * the caller the same way, so a client cannot probe the registry through it. + */ +export async function findLinkedOidcClient(client: OidcClient, linkedId: string) { + const link = await OidcClientLink.findOne({ + where: { clientId: client.id, linkedClientId: linkedId }, + }); + if (!link) return null; + + const linked = await findActiveOidcClient(linkedId); + if (!linked || (await linkProblem(client, linked))) return null; + + return linked; +} diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index c48af20d..60f62249 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -31,6 +31,8 @@ const GUARDED = [ 'POST /admin/applications/{id}/secret', 'PUT /admin/applications/{id}/keys', 'PUT /admin/applications/{id}/resources', + 'PUT /admin/applications/{id}/links/{linkedId}', + 'DELETE /admin/applications/{id}/links/{linkedId}', 'POST /admin/resources', 'PATCH /admin/resources/{id}', 'DELETE /admin/resources/{id}', diff --git a/tests/integration/applications/accountNotices.spec.ts b/tests/integration/applications/accountNotices.spec.ts index af54a67f..40761f0b 100644 --- a/tests/integration/applications/accountNotices.spec.ts +++ b/tests/integration/applications/accountNotices.spec.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/models/oidcPayloads.js', () => ({ import { createApp } from '../../../src/app'; import { getSystemConfig } from '../../../src/config/getSystemConfig'; +import { OidcClientLink } from '../../../src/models/oidcClientLinks.js'; import { OidcClient } from '../../../src/models/oidcClients.js'; import { OidcPayload } from '../../../src/models/oidcPayloads.js'; import { User } from '../../../src/models/users.js'; @@ -42,6 +43,7 @@ function client(overrides: Record = {}) { grantTypes: ['authorization_code', 'client_credentials'], redirectUris: ['https://app.locksley.example/callback', 'http://127.0.0.1/callback'], noticesEnabled: true, + noticeActionOrigins: [], disabledAt: null, ...overrides, }; @@ -366,3 +368,157 @@ describe('POST /applications/account-notices', () => { }); }); }); + +describe('POST /applications/account-notices for a linked client', () => { + const backend = client({ + clientType: 'confidential', + firstParty: true, + redirectUris: [], + noticeActionOrigins: ['https://store.locksley.example'], + }); + const desktop = client({ + id: 'locksley-desktop', + name: 'Locksley Desktop', + clientType: 'public', + firstParty: true, + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['http://127.0.0.1/callback'], + noticesEnabled: false, + }); + + function registry(...clients: Record[]) { + const byId = new Map(clients.map((c) => [c.id, c])); + vi.mocked(OidcClient.findByPk).mockImplementation( + async (id: unknown) => (byId.get(id as string) ?? null) as never, + ); + } + + /** The user signed in to the desktop app only. */ + function grantsFor(clientId: string) { + vi.mocked(OidcPayload.findOne).mockImplementation(async (options) => { + const where = (options as { where: { payload: { clientId: string } } }).where; + return ( + where.payload.clientId === clientId ? { payload: { accountId: USER, clientId } } : null + ) as never; + }); + } + + beforeEach(() => { + registry(backend, desktop); + grantsFor('locksley-desktop'); + vi.mocked(OidcClientLink.findOne).mockImplementation(async (options) => { + const where = (options as { where: Record }).where; + return ( + where.clientId === 'locksley' && where.linkedClientId === 'locksley-desktop' + ? { clientId: 'locksley', linkedClientId: 'locksley-desktop' } + : null + ) as never; + }); + }); + + it('sends to a user of the linked client, named as that client', async () => { + const res = await send({ + ...notice, + clientId: 'locksley-desktop', + channels: ['email'], + actionUrl: 'https://store.locksley.example/recoveries/cancel', + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ channels: [{ channel: 'email', status: 'sent' }] }); + expect(sendNoticeEmail).toHaveBeenCalledWith( + 'test@example.com', + '[Locksley Desktop] Device recovery started', + 'A recovery opened.\n\nhttps://store.locksley.example/recoveries/cancel\n\n--\nSent by Locksley Desktop through Acme ID.', + ); + expect(OidcPayload.findOne).toHaveBeenCalledWith({ + where: expect.objectContaining({ + model: 'Grant', + payload: { accountId: USER, clientId: 'locksley-desktop' }, + }), + }); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oidc_account_notice_sent', + userId: USER, + metadata: expect.objectContaining({ + clientId: 'locksley', + sentForClientId: 'locksley-desktop', + }), + }), + ); + }); + + it('does not reach the same user without naming the linked client', async () => { + const res = await send(); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'notice_unknown_user' }); + }); + + it('treats its own client id as no client id', async () => { + grantsFor('locksley'); + + const res = await send({ ...notice, clientId: 'locksley' }); + + expect(res.status).toBe(200); + expect(OidcClientLink.findOne).not.toHaveBeenCalled(); + expect(vi.mocked(sendNoticeEmail).mock.calls[0][1]).toBe('[Locksley] Device recovery started'); + }); + + it.each([ + ['a client it is not linked to', () => registry(backend, desktop, { ...desktop, id: 'other' })], + ['a client that does not exist', () => undefined], + ])('answers the same 404 as an unknown user for %s', async (_label, arrange) => { + arrange(); + grantsFor('other'); + + const res = await send({ ...notice, clientId: 'other' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'notice_unknown_user' }); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oidc_account_notice_failed', + metadata: { clientId: 'locksley', sentForClientId: 'other', reason: 'notice_unknown_user' }, + }), + ); + }); + + it.each([ + ['is disabled', { disabledAt: new Date() }], + ['moved across the first-party boundary', { firstParty: false }], + ['became confidential', { clientType: 'confidential' }], + ])('answers 404 once the linked client %s', async (_label, change) => { + registry(backend, { ...desktop, ...change }); + + const res = await send({ ...notice, clientId: 'locksley-desktop' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'notice_unknown_user' }); + expect(sendNoticeEmail).not.toHaveBeenCalled(); + }); + + it('answers 404 for a user who did not sign in to the linked client', async () => { + grantsFor('somewhere-else'); + + const res = await send({ ...notice, clientId: 'locksley-desktop' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'notice_unknown_user' }); + }); + + it("checks the action link against the caller's origins, not the linked client's", async () => { + registry(backend, { ...desktop, redirectUris: ['https://desktop.example/callback'] }); + + const res = await send({ + ...notice, + clientId: 'locksley-desktop', + actionUrl: 'https://desktop.example/cancel', + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'notice_action_url_not_allowed' }); + }); +}); diff --git a/tests/integration/oidc/oidcApplications.spec.ts b/tests/integration/oidc/oidcApplications.spec.ts index 8118d4fe..4a6e606f 100644 --- a/tests/integration/oidc/oidcApplications.spec.ts +++ b/tests/integration/oidc/oidcApplications.spec.ts @@ -1,4 +1,5 @@ import { Application } from 'express'; +import { Op } from 'sequelize'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -21,6 +22,7 @@ vi.mock('../../../src/models/oidcPayloads.js', () => ({ import { createApp } from '../../../src/app'; import { getSystemConfig } from '../../../src/config/getSystemConfig'; +import { OidcClientLink } from '../../../src/models/oidcClientLinks.js'; import { OidcClientResource } from '../../../src/models/oidcClientResources.js'; import { OidcClient } from '../../../src/models/oidcClients.js'; import { OidcPayload } from '../../../src/models/oidcPayloads.js'; @@ -58,6 +60,7 @@ function row(overrides: Record = {}): Row { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + noticeActionOrigins: [], pkceRequired: true, nonceRequired: true, disabledAt: null, @@ -379,6 +382,110 @@ describe('POST /admin/applications', () => { }); }); +describe('noticeActionOrigins', () => { + it('is set on create, returned, and audited', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'Registry', + clientType: 'confidential', + firstParty: true, + grantTypes: ['client_credentials'], + noticeActionOrigins: ['https://store.example.com'], + }); + + expect(res.status).toBe(201); + expect(res.body.application).toMatchObject({ + noticeActionOrigins: ['https://store.example.com'], + links: [], + linkedFrom: [], + }); + expect(OidcClient.create).toHaveBeenCalledWith( + expect.objectContaining({ noticeActionOrigins: ['https://store.example.com'] }), + ); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0].metadata).toMatchObject({ + noticeActionOrigins: ['https://store.example.com'], + }); + }); + + it('defaults to none on create', async () => { + await request(app) + .post('/admin/applications') + .send({ + name: 'Desktop', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/cb'], + }); + + expect(OidcClient.create).toHaveBeenCalledWith( + expect.objectContaining({ noticeActionOrigins: [] }), + ); + }); + + it('is changed by a patch, and audited', async () => { + const existing = row(); + vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); + + const res = await request(app) + .patch('/admin/applications/app-1') + .send({ noticeActionOrigins: ['https://store.example.com'] }); + + expect(res.status).toBe(200); + expect(res.body.application.noticeActionOrigins).toEqual(['https://store.example.com']); + expect(existing.update).toHaveBeenCalledWith( + expect.objectContaining({ noticeActionOrigins: ['https://store.example.com'] }), + ); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'admin_oidc_application_updated', + metadata: { changed: ['noticeActionOrigins'] }, + }); + }); + + it('is left alone by a patch that does not name it', async () => { + const existing = row({ noticeActionOrigins: ['https://store.example.com'] }); + vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); + + await request(app).patch('/admin/applications/app-1').send({ name: 'Renamed' }); + + expect(existing.update.mock.calls[0][0]).not.toHaveProperty('noticeActionOrigins'); + expect(existing.noticeActionOrigins).toEqual(['https://store.example.com']); + }); + + it.each([ + [['http://store.example.com']], + [['https://127.0.0.1']], + [['https://[::1]']], + [['https://localhost']], + [['https://app.localhost']], + [['https://store.example.com/']], + [['https://store.example.com/recover']], + [['https://user:pass@store.example.com']], + [['not a url']], + [['https://store.example.com', 'https://store.example.com']], + ])('refuses %j', async (noticeActionOrigins) => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(row() as never); + + const created = await request(app) + .post('/admin/applications') + .send({ + name: 'Registry', + clientType: 'confidential', + firstParty: true, + grantTypes: ['client_credentials'], + noticeActionOrigins, + }); + const patched = await request(app) + .patch('/admin/applications/app-1') + .send({ noticeActionOrigins }); + + expect(created.status).toBe(400); + expect(created.body.details.issues[0].path[0]).toBe('noticeActionOrigins'); + expect(patched.status).toBe(400); + expect(OidcClient.create).not.toHaveBeenCalled(); + }); +}); + describe('PATCH /admin/applications/:id', () => { it('changes one field and audits what changed', async () => { const existing = row(); @@ -654,7 +761,7 @@ describe('POST /admin/applications/:id/secret', () => { }); describe('DELETE /admin/applications/:id', () => { - it('deletes the application, its resource grants and its provider records', async () => { + it('deletes the application, its resource grants, its links and its provider records', async () => { const existing = row(); vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); @@ -669,6 +776,10 @@ describe('DELETE /admin/applications/:id', () => { where: { clientId: 'app-1' }, transaction: { tx: true }, }); + expect(OidcClientLink.destroy).toHaveBeenCalledWith({ + where: { [Op.or]: [{ clientId: 'app-1' }, { linkedClientId: 'app-1' }] }, + transaction: { tx: true }, + }); expect(existing.destroy).toHaveBeenCalledWith({ transaction: { tx: true } }); expect(loggedTypes()).toEqual(['admin_oidc_application_deleted']); }); diff --git a/tests/integration/oidc/oidcClientLinks.spec.ts b/tests/integration/oidc/oidcClientLinks.spec.ts new file mode 100644 index 00000000..bd7a7fa8 --- /dev/null +++ b/tests/integration/oidc/oidcClientLinks.spec.ts @@ -0,0 +1,276 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/models/oidcClients.js', () => ({ + OidcClient: { findAll: vi.fn(), findByPk: vi.fn() }, +})); + +import { createApp } from '../../../src/app'; +import { getSystemConfig } from '../../../src/config/getSystemConfig'; +import { OidcClientLink } from '../../../src/models/oidcClientLinks.js'; +import { OidcClient } from '../../../src/models/oidcClients.js'; +import { Session } from '../../../src/models/sessions.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { buildFreshStepUpSession } from '../../factories/sessionFactory'; +import { buildSystemConfig } from '../../factories/systemConfigFactory'; + +const CREATED = new Date('2026-11-02T12:00:00.000Z'); + +let app: Application; + +function client(id: string, overrides: Record = {}) { + return { + id, + name: id, + clientType: 'public', + firstParty: true, + tokenEndpointAuthMethod: 'none', + jwks: null, + jwksUri: null, + secretLookup: null, + redirectUris: ['http://127.0.0.1/callback'], + postLogoutRedirectUris: [], + grantTypes: ['authorization_code', 'refresh_token'], + backchannelLogoutUri: null, + accessTokenTtlSeconds: null, + idTokenTtlSeconds: null, + refreshTokenTtlSeconds: null, + deviceFlowEnabled: false, + noticesEnabled: false, + noticeActionOrigins: [], + pkceRequired: true, + nonceRequired: true, + disabledAt: null, + createdAt: CREATED, + updatedAt: CREATED, + ...overrides, + }; +} + +const backend = (overrides: Record = {}) => + client('registry', { + clientType: 'confidential', + tokenEndpointAuthMethod: 'private_key_jwt', + redirectUris: [], + grantTypes: ['client_credentials'], + ...overrides, + }); + +function registry(...clients: ReturnType[]) { + const byId = new Map(clients.map((c) => [c.id, c])); + vi.mocked(OidcClient.findByPk).mockImplementation( + async (id: unknown) => (byId.get(id as string) ?? null) as never, + ); +} + +function link(clientId: string, linkedClientId: string) { + return { clientId, linkedClientId, createdBy: 'admin-1', createdAt: CREATED }; +} + +function loggedEvents() { + return vi.mocked(AuthEventService.log).mock.calls.map(([event]) => ({ + type: event.type, + metadata: event.metadata, + })); +} + +function withConfig(overrides: Record = {}) { + vi.mocked(getSystemConfig).mockResolvedValue( + buildSystemConfig({ third_party_clients_enabled: false, ...overrides }) as never, + ); +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(AuthEventService, 'log').mockResolvedValue(undefined); + (Session.findOne as ReturnType).mockResolvedValue(buildFreshStepUpSession()); + vi.mocked(OidcClientLink.findAll).mockResolvedValue([]); + vi.mocked(OidcClientLink.findOrCreate).mockImplementation( + async ({ defaults }: { defaults?: Record }) => + [{ ...defaults, createdAt: CREATED }, true] as never, + ); + withConfig(); + registry(backend(), client('desktop')); +}); + +describe('GET /admin/applications/:id/links', () => { + it('lists the links in both directions', async () => { + vi.mocked(OidcClientLink.findAll).mockImplementation(async (options) => { + const where = (options as { where: Record }).where; + return (where.clientId ? [link('registry', 'desktop')] : []) as never; + }); + + const res = await request(app).get('/admin/applications/registry/links'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + links: [ + { + clientId: 'registry', + linkedClientId: 'desktop', + createdBy: 'admin-1', + createdAt: CREATED.toISOString(), + }, + ], + linkedFrom: [], + }); + }); + + it('answers 404 for an unknown application', async () => { + const res = await request(app).get('/admin/applications/nope/links'); + + expect(res.status).toBe(404); + expect(OidcClientLink.findAll).not.toHaveBeenCalled(); + }); +}); + +describe('application responses', () => { + it('list the clients an application acts for and the ones acting for it', async () => { + vi.mocked(OidcClient.findAll).mockResolvedValue([backend(), client('desktop')] as never); + vi.mocked(OidcClientLink.findAll).mockResolvedValue([link('registry', 'desktop')] as never); + + const res = await request(app).get('/admin/applications'); + + expect(res.status).toBe(200); + expect(res.body.applications).toEqual([ + expect.objectContaining({ + id: 'registry', + links: ['desktop'], + linkedFrom: [], + noticeActionOrigins: [], + }), + expect.objectContaining({ id: 'desktop', links: [], linkedFrom: ['registry'] }), + ]); + }); +}); + +describe('PUT /admin/applications/:id/links/:linkedId', () => { + it('links a confidential backend to a public client of the same party, and audits it', async () => { + const res = await request(app).put('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(200); + expect(res.body.link).toMatchObject({ clientId: 'registry', linkedClientId: 'desktop' }); + expect(OidcClientLink.findOrCreate).toHaveBeenCalledWith({ + where: { clientId: 'registry', linkedClientId: 'desktop' }, + defaults: { clientId: 'registry', linkedClientId: 'desktop', createdBy: 'user-1' }, + }); + expect(loggedEvents()).toEqual([ + { + type: 'admin_oidc_application_link_added', + metadata: { clientId: 'registry', linkedClientId: 'desktop' }, + }, + ]); + }); + + it('does not audit a link that already existed', async () => { + vi.mocked(OidcClientLink.findOrCreate).mockResolvedValue([ + link('registry', 'desktop'), + false, + ] as never); + + const res = await request(app).put('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(200); + expect(loggedEvents()).toEqual([]); + }); + + it.each([ + [ + 'a public client acting', + () => registry(client('registry'), client('desktop')), + 'Only a confidential application can act for another', + ], + [ + 'a confidential client acted for', + () => registry(backend(), backend({ id: 'desktop' })), + 'Only a public application can be acted for; a confidential one sends its own notices', + ], + [ + 'a first-party backend reaching a third-party client', + () => registry(backend(), client('desktop', { firstParty: false })), + 'Links stay within first-party or within third-party applications', + ], + [ + 'a third-party backend reaching a first-party client', + () => { + withConfig({ third_party_clients_enabled: true }); + registry(backend({ firstParty: false }), client('desktop')); + }, + 'Links stay within first-party or within third-party applications', + ], + [ + 'third-party clients while they are off', + () => registry(backend({ firstParty: false }), client('desktop', { firstParty: false })), + 'Third-party clients are off on this instance (third_party_clients_enabled)', + ], + ])('refuses %s', async (_label, arrange, error) => { + arrange(); + + const res = await request(app).put('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error }); + expect(OidcClientLink.findOrCreate).not.toHaveBeenCalled(); + expect(loggedEvents()).toEqual([]); + }); + + it('refuses a link to itself', async () => { + const res = await request(app).put('/admin/applications/registry/links/registry'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'An application cannot be linked to itself' }); + }); + + it('links third-party clients once third-party clients are on', async () => { + withConfig({ third_party_clients_enabled: true }); + registry(backend({ firstParty: false }), client('desktop', { firstParty: false })); + + const res = await request(app).put('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(200); + }); + + it.each(['/admin/applications/nope/links/desktop', '/admin/applications/registry/links/nope'])( + 'answers 404 for an unknown application (%s)', + async (path) => { + const res = await request(app).put(path); + + expect(res.status).toBe(404); + expect(OidcClientLink.findOrCreate).not.toHaveBeenCalled(); + }, + ); +}); + +describe('DELETE /admin/applications/:id/links/:linkedId', () => { + it('removes the link and audits it', async () => { + vi.mocked(OidcClientLink.destroy).mockResolvedValue(1); + + const res = await request(app).delete('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(200); + expect(OidcClientLink.destroy).toHaveBeenCalledWith({ + where: { clientId: 'registry', linkedClientId: 'desktop' }, + }); + expect(loggedEvents()).toEqual([ + { + type: 'admin_oidc_application_link_removed', + metadata: { clientId: 'registry', linkedClientId: 'desktop' }, + }, + ]); + }); + + it('answers 404 when there is no such link', async () => { + vi.mocked(OidcClientLink.destroy).mockResolvedValue(0); + + const res = await request(app).delete('/admin/applications/registry/links/desktop'); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Link not found' }); + expect(loggedEvents()).toEqual([]); + }); +}); diff --git a/tests/postgres/oidcApplications.spec.ts b/tests/postgres/oidcApplications.spec.ts index 8da10a3f..7dcaa002 100644 --- a/tests/postgres/oidcApplications.spec.ts +++ b/tests/postgres/oidcApplications.spec.ts @@ -2,11 +2,17 @@ import { randomUUID } from 'crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { OidcClientLink } from '../../src/models/oidcClientLinks.js'; import { OidcClientResource } from '../../src/models/oidcClientResources.js'; import { OidcClient } from '../../src/models/oidcClients.js'; import { OidcPayload } from '../../src/models/oidcPayloads.js'; import { OidcResource } from '../../src/models/oidcResources.js'; import { deleteOidcApplication } from '../../src/services/oidcApplicationService.js'; +import { + addOidcApplicationLink, + findLinkedOidcClient, + listOidcApplicationLinks, +} from '../../src/services/oidcClientLinkService.js'; import { getOidcClientResourceScopes } from '../../src/services/oidcClientRegistry.js'; import { upsertOidcPayload } from '../../src/services/oidcPayloadStore.js'; import { @@ -28,12 +34,24 @@ async function createClient(id: string) { return OidcClient.create({ id, name: id, + firstParty: true, clientType: 'public', tokenEndpointAuthMethod: 'none', redirectUris: ['http://127.0.0.1/cb'], }); } +async function createBackend(id: string) { + return OidcClient.create({ + id, + name: id, + firstParty: true, + clientType: 'confidential', + tokenEndpointAuthMethod: 'private_key_jwt', + grantTypes: ['client_credentials'], + }); +} + describe('deleting an application', () => { it('removes its resource grants and provider records, and no other client', async () => { const id = `app-${randomUUID()}`; @@ -90,3 +108,52 @@ describe('application resources', () => { expect((await getOidcClientResourceScopes(id)).size).toBe(0); }); }); + +describe('application links', () => { + it('let a backend find the public client it acts for, and no other', async () => { + const backend = await createBackend(`app-${randomUUID()}`); + const desktop = await createClient(`app-${randomUUID()}`); + const stranger = await createClient(`app-${randomUUID()}`); + + expect(backend.noticeActionOrigins).toEqual([]); + + const admin = randomUUID(); + const first = await addOidcApplicationLink(backend.id, desktop.id, admin); + const again = await addOidcApplicationLink(backend.id, desktop.id, admin); + expect(first.created).toBe(true); + expect(again.created).toBe(false); + expect(first.link).toMatchObject({ createdBy: admin }); + + expect((await listOidcApplicationLinks(desktop.id)).linkedFrom).toEqual([ + expect.objectContaining({ clientId: backend.id, linkedClientId: desktop.id }), + ]); + expect((await findLinkedOidcClient(backend, desktop.id))?.id).toBe(desktop.id); + expect(await findLinkedOidcClient(backend, stranger.id)).toBeNull(); + + await desktop.update({ disabledAt: new Date() }); + expect(await findLinkedOidcClient(backend, desktop.id)).toBeNull(); + }); + + it('refuse a link to the same client', async () => { + const backend = await createBackend(`app-${randomUUID()}`); + + await expect( + OidcClientLink.create({ clientId: backend.id, linkedClientId: backend.id }), + ).rejects.toThrow(); + }); + + it('go when either client is deleted', async () => { + const backend = await createBackend(`app-${randomUUID()}`); + const desktop = await createClient(`app-${randomUUID()}`); + const web = await createClient(`app-${randomUUID()}`); + await addOidcApplicationLink(backend.id, desktop.id, null); + await addOidcApplicationLink(backend.id, web.id, null); + + await deleteOidcApplication(desktop.id); + expect(await OidcClientLink.count({ where: { linkedClientId: desktop.id } })).toBe(0); + expect(await OidcClientLink.count({ where: { clientId: backend.id } })).toBe(1); + + await web.destroy(); + expect(await OidcClientLink.count({ where: { clientId: backend.id } })).toBe(0); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index b42a030e..cbfdfb45 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -61,6 +61,17 @@ vi.mock('../../src/models/policyDrafts.js', () => ({ }, })); +// Every admin application response lists its links, so specs that do not exercise links +// see none. +vi.mock('../../src/models/oidcClientLinks.js', () => ({ + OidcClientLink: { + findAll: vi.fn(async () => []), + findOne: vi.fn(async () => null), + findOrCreate: vi.fn(), + destroy: vi.fn(async () => 0), + }, +})); + vi.mock('../../src/models/authFailures.js', () => ({ AuthFailure: { create: vi.fn(), diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 72fe41a5..4ed7e1e5 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -17,6 +17,7 @@ vi.unmock('../../../src/models/webauthnChallenges.js'); vi.unmock('../../../src/models/userExternalIds.js'); vi.unmock('../../../src/models/policyVersions.js'); vi.unmock('../../../src/models/policyDrafts.js'); +vi.unmock('../../../src/models/oidcClientLinks.js'); // The predicate the loader filters on. It used to ask whether a file ended with its own // extension, which is always true, so everything but index was imported and required to