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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oidc-client-links.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
12 changes: 12 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0001-openid-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <application> through <instance>". 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.
Expand Down
28 changes: 14 additions & 14 deletions docs/adr/0002-native-and-desktop-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading