Appearance
User Authentication & Login — API Analysis (API-A)
Concept layer — frozen. The User Login & Authentication generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Feature: User Authentication & Login
Four groups of endpoints: initialising the sign-in surface, the post-exchange identity sync, the authenticated user's own profile, and administration of linked identities and sign-in configuration.
Paths below are written without any version or gateway prefix: how a project versions and mounts the routes is an application decision, not part of the concept. Success envelope: { data, status, message, requestId }. Permission codes are named descriptively; a project resolves them against its own Permission Model catalog.
Endpoint index: the git host renders an outline from the
##headings below — there is no hand-maintained endpoint list to fall out of date.
1. Initialize the sign-in surface
Endpoint: GET /login?locale=$locale
Description
Everything the sign-in surface needs to render for a locale: branding, the providers on offer, localized text, policy links and an administrator contact. This is the one read that must work for a caller with no identity at all.
Authorization
Public. No token, no permission. The response must therefore contain nothing that is not already safe to show an anonymous visitor — which is the real constraint on what may be added to it.
A permission code registered against a route that is public is a trap: it reads as enforcement and enforces nothing. Either the route is public and carries no code, or it is not public.
Request Headers
None beyond the project's shared conventions.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
locale | String | Yes | Locale selecting the text and reference values to return; an unsupported locale falls back to the default and the response says so |
Request Logic
Reads the sign-in configuration and the text for the requested (or fallback) locale. Read-only, and deliberately cheap: it is called before every sign-in, including by clients that never complete one.
Success Response (200 OK)
json
{
"data": {
"translations": { "locale": "en-GB", "data": { "login.title": "Sign in" } },
"branding": {
"brandName": "Example Corp",
"logoBase64": null,
"primaryButtonColor": "#1E90FF",
"borderRadiusPx": 8,
"background": { "type": "solid", "color": "#F3F4F6" }
},
"providers": [ { "key": "corporate-directory", "type": "oidc" } ],
"privacyPolicyUrl": null,
"termsAndConditionsUrl": null,
"administratorContactName": null,
"administratorContactEmail": null
},
"status": 200,
"message": "Sign-in surface initialized successfully.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Response Data Mapping
| Response field | Source / value |
|---|---|
branding, providers, policy and contact fields | Sign-in configuration |
translations | Text for the resolved locale |
message | Carries the fallback notice when the requested locale is unsupported |
Error Responses
| Status | Condition |
|---|---|
| 400 | locale missing or malformed |
2. Sync the authenticated identity
Endpoint: POST /identity/sync
Description
Called by the sign-in layer immediately after the token exchange: resolve the token's identity to an internal user, link it where the project's linking rules allow, and refresh the sign-in snapshot.
Authorization
Requires a valid token but does not require the identity to resolve to a user yet — that is the question this endpoint answers. It is the one authenticated route where "unknown identity" is a normal input rather than an error condition.
Request Headers
text
Authorization: Bearer <access_token>Request Body
None. Every input comes from the token's claims — which is the point: a body would let the caller assert an identity rather than prove one.
Request Logic
- Look up the identity by
(issuer, subject). - If absent, apply the project's linking rules — administrative-only, or automatic under the precondition described on the feature page.
- On success, refresh the last-sign-in timestamp and the snapshots, and populate the identity cache.
Transactional Operations
The resolve-or-link and the sign-in touch commit in one transaction; caches are populated after commit, never inside it.
Success Response (200 OK)
json
{
"data": { "userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10" },
"status": 200,
"message": "Identity synced.",
"requestId": "3ab34d88-65d1-4c10-897a-237c9a5b116f"
}Error Responses
| Status | Condition |
|---|---|
| 401 | Token missing, invalid, expired, or from an untrusted issuer |
| 401 | Token valid but the identity resolves to no internal user |
Both cases are 401, and a project should keep them distinguishable in the payload without distinguishing them in the status. "I do not know you" and "I know you and you are nobody here" are different problems for support and identical for the client. Note also that a genuine 401 is easily mislabelled as a validation error when a generic validation fallback runs before the status-to-code mapping — worth checking, because it makes every unmapped identity look like a malformed request.
3. Read the current user's profile
Endpoint: GET /me
Description
The profile of the currently authenticated user.
Authorization
Requires authentication; a permission granted to any authenticated user. Not scope-bound, and it takes no user identifier — the subject is the caller. An endpoint that accepted a user id here would be a different endpoint with a different permission.
Request Headers
text
Authorization: Bearer <access_token>Request Logic
Reads the resolved user from the authenticated context. Read-only.
Success Response (200 OK)
json
{
"data": {
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"displayName": "Anna Novak",
"email": "anna.novak@example.com",
"avatarUrl": null
},
"status": 200,
"message": "Profile loaded successfully.",
"requestId": "ad9cd51f-d993-4014-a2b2-8f563fe887fd"
}Error Responses
| Status | Condition |
|---|---|
| 401 | No valid token |
4. Attach an identity to a user
Endpoint: POST /admin/users/{userId}/identities
Description
Link an external identity to an existing user. The administrative counterpart to automatic linking, and the only path a project that disables automatic linking has.
Authorization
An administrative create permission over user identities. Identities are typically not scope-bound (see Are identities global, or scoped? on the feature page), so this is usually a platform-level permission rather than a scoped one.
Request Body
json
{ "providerKey": "corporate-directory", "subjectRef": "67d3401f-1a2b-4c3d-9e8f-000000000000" }| Field | Type | Required | Description |
|---|---|---|---|
providerKey | String | Yes | One of the supported providers |
subjectRef | String | Yes | The subject as the administrator knows it |
subjectRefis not necessarily the stored subject. Providers identify people in their own formats, and the value an administrator can copy out of a directory is often not the value that arrives in a token. The concept therefore separates the two: the request takes a provider-appropriate reference, and the service derives the issuer and the canonical subject from it. A project must say, per provider, what that reference is and how it converts — and must not validatesubjectRefagainst one provider's format at the input layer, which would reject every other provider's references before the provider-specific check can produce a useful error.
Request Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
userId | Path | UUID | Yes | User to attach the identity to |
Request Logic
Validate the provider and the reference, derive issuer and subject, then create or restore the identity row.
Transactional Operations
One transaction. The unique (issuer, subject) constraint plus duplicate-key handling makes concurrent creates safe; a previously soft-deleted row is restored rather than duplicated.
Success Response (201 Created)
json
{
"data": {
"id": "018fa520-1c33-7a90-9d21-9f0b2a44e8c1",
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"providerKey": "corporate-directory",
"issuer": "https://sso.example.com/",
"subject": "67d3401f-1a2b-4c3d-9e8f-000000000000"
},
"status": 201,
"message": "Created user identity.",
"requestId": "16ddc487-1092-496e-b9a4-97d3e3082ee6"
}Error Responses
| Status | Condition |
|---|---|
| 400 | Provider not supported |
| 400 | Subject reference not valid for that provider |
| 403 | Caller lacks the permission |
| 404 | User does not exist |
| 409 | That (issuer, subject) is already linked — possibly to a different user |
The 409 is the interesting one: it means someone else may already own this credential. The response must not disclose which user, and the resolution is an administrative decision rather than an overwrite.
5. List a user's identities
Endpoint: GET /admin/users/{userId}/identities
Description
The external identities linked to a user — the answer to "why can this person sign in, and by which route".
Authorization
An administrative read permission over user identities.
Request Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
userId | Path | UUID | Yes | User whose identities to list |
Request Logic
Reads the user's live identity rows. Read-only.
Success Response (200 OK)
json
{
"data": {
"identities": [
{
"id": "018fa520-1c33-7a90-9d21-9f0b2a44e8c1",
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"providerKey": "corporate-directory",
"issuer": "https://sso.example.com/",
"subject": "67d3401f-1a2b-4c3d-9e8f-000000000000",
"lastLoginAt": "2026-05-12T08:14:22Z",
"createdAt": "2026-01-22T10:00:00Z",
"updatedAt": "2026-01-22T10:00:00Z"
}
]
},
"status": 200,
"message": "Fetched user identities.",
"requestId": "16ddc487-1092-496e-b9a4-97d3e3082ee6"
}Error Responses
| Status | Condition |
|---|---|
| 403 | Caller lacks the permission |
| 404 | User does not exist |
6. Re-point an identity
Endpoint: PUT /admin/users/{userId}/identities/{identityId}
Description
Change which external credential a link points at — the repair path when a directory migration changes a person's subject.
Authorization
An administrative update permission over user identities.
Request Body
Same shape and validation as create.
Request Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
userId | Path | UUID | Yes | Owning user |
identityId | Path | UUID | Yes | Identity to re-point |
Request Logic
Validate, derive issuer and subject, update the row, and evict the cache entry for the old(issuer, subject) — the pair that is no longer valid is the one still cached.
Transactional Operations
One transaction; the unique constraint guards against collisions with another user's identity.
Success Response (200 OK)
json
{
"data": {
"id": "018fa520-1c33-7a90-9d21-9f0b2a44e8c1",
"userId": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
"providerKey": "corporate-directory",
"issuer": "https://sso.example.com/",
"subject": "b3d20b91-05ff-4f4d-8a29-c70e4405acde"
},
"status": 200,
"message": "Updated user identity.",
"requestId": "ad9cd51f-d993-4014-a2b2-8f563fe887fd"
}Error Responses
| Status | Condition |
|---|---|
| 400 | Provider not supported, or subject reference invalid for it |
| 403 | Caller lacks the permission |
| 404 | User or identity does not exist |
| 409 | The new (issuer, subject) collides with an existing identity |
7. Remove an identity
Endpoint: DELETE /admin/users/{userId}/identities/{identityId}
Description
Unlink an external credential. Soft delete, because the audit trail must keep referring to something.
Authorization
An administrative delete permission over user identities.
Request Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
userId | Path | UUID | Yes | Owning user |
identityId | Path | UUID | Yes | Identity to remove |
Request Logic
Soft-delete the row and evict the cache entry for its (issuer, subject). Skipping the eviction leaves the removed identity working until the TTL expires, which is the whole point of removing it.
Transactional Operations
The soft delete commits in one transaction; the cache entry is evicted after commit.
Success Response (204 No Content)
Empty body.
Error Responses
| Status | Condition |
|---|---|
| 403 | Caller lacks the permission |
| 404 | User or identity does not exist |
8. Read the sign-in configuration
Endpoint: GET /admin/settings/sign-in
Description
The current branding, offered providers, policy links and administrator contact — the administrative view of what endpoint 1 serves anonymously.
Authorization
An administrative read permission over sign-in settings.
Request Logic
Reads the configuration (the Config Store concept). Read-only.
Success Response (200 OK)
json
{
"data": {
"branding": { "brandName": "Example Corp", "primaryButtonColor": "#1E90FF", "borderRadiusPx": 8 },
"providers": [ { "key": "corporate-directory", "type": "oidc" } ],
"privacyPolicyUrl": null,
"termsAndConditionsUrl": null,
"administratorContactName": null,
"administratorContactEmail": null
},
"status": 200,
"message": "Sign-in settings loaded successfully.",
"requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}Error Responses
| Status | Condition |
|---|---|
| 403 | Caller lacks the permission |
9. Update the sign-in configuration
Endpoint: PUT /admin/settings/sign-in
Description
Write the branding, policy links and contact, then return the re-read result.
Authorization
An administrative update permission over sign-in settings.
Request Body
json
{
"branding": {
"brandName": "Example Corp",
"logoBase64": null,
"primaryButtonColor": "#1E90FF",
"borderRadiusPx": 8,
"background": { "type": "linear-gradient", "angleDeg": 135, "colors": ["#F3F4F6", "#FFFFFF"] }
},
"privacyPolicyUrl": null,
"termsAndConditionsUrl": null,
"administratorContactName": null,
"administratorContactEmail": null
}Every branding value is format-validated — colours, numeric ranges, URLs, the contact address. The values are rendered on a public, unauthenticated page, so validation here is not cosmetic: it is the boundary that stops administrative input becoming anonymous-facing content.
The enabled-provider list is deliberately not editable here. Turning a provider on requires issuer and client configuration that this endpoint does not carry, so offering it as a checkbox invites a sign-in surface advertising a provider that cannot complete a flow.
Request Logic
Validate, write, re-read, return. Returning the re-read rather than the request body is what makes the response honest about what was actually stored.
Transactional Operations
The configuration write commits in one transaction; the settings are re-read after commit.
Success Response (200 OK)
Same shape as the read.
Error Responses
| Status | Condition |
|---|---|
| 400 | Invalid branding value, malformed URL or contact address |
| 403 | Caller lacks the permission |
Internal / non-endpoint access
Two paths carry more traffic than every endpoint above and are not endpoints at all:
- Per-request token validation. On each protected request the bearer token is validated (signature against the issuer's keys; issuer, audience and expiry against the trusted set), then
(issuer, subject)is resolved to an internal user — from the identity cache first, from the database second. Where automatic linking is enabled and its precondition holds, an unknown identity may be linked here rather than rejected; otherwise the request is unauthenticated. This is the hot path, and it is the reason the identity cache exists. - Identity repository writes. Identity rows are created, restored, sign-in-touched, updated and soft-deleted through an internal repository that records the acting party and joins the surrounding transaction. There is no public write path beyond the administrative endpoints above.
A product that exposes more than one application surface commonly adds one further authenticated read: which surfaces may this user enter, and in which scope. Its shape depends entirely on how the project divides its surfaces and how it scopes them, so the concept notes the need and leaves the contract to the project — it belongs to whichever feature owns the shell, not to authentication.