# API-owned login and compatibility contracts Status: implemented behind explicit installation enrolment. The public browser protocol is included in the assembled browser OpenAPI contract and generated web client. The [API runtime guide](../../services/api/adaptors/authentication/README.md) records the route, worker and credential configuration. Retained Themelio requires API-owned authentication; the NextAuth bridge has been retired. Limited sessions receive only their own Party readiness notifications. The [transition plan](../../services/beto/docs/party-project/authentication-transition.md) records the coordinated cutover and endpoint migration. ## Boundaries | Specification | Server and caller | Responsibility | | --- | --- | --- | | [Browser protocol](browser.openapi.yaml) | Go API; browser | OIDC, limited session status, explicit onboarding resume and logout. | | [Legacy auth protocol](legacy.openapi.yaml) | Go API; enrolled legacy adaptor or credential worker | Operation-scoped session validation, revocation and non-bearer credential-work fences. | | [Compatibility protocol](compatibility.openapi.yaml) | Retained compatibility integration; enrolled API dispatcher or policy reader | Independent legacy AUP reads and durable account/credential work. | | [Party authentication v3](../party_governance/authentication/README.md) | Beto Party Governance; enrolled API | Canonical Party registration and current identity eligibility from qualified API evidence. | Public technical paths live under `/auth`; private `/internal` paths are excluded from public ingress. This interface adds no Beto business HTTP surface. Party registration and queries still use typed RabbitMQ. These standalone specifications remain separate from the business route registry. Private compatibility and legacy specifications are absent from the public browser OpenAPI document. Login start and RP-initiated logout accept native form POSTs followed by top-level redirects. Other HTTP bodies are strict UTF-8 JSON; all request bodies have a 32 KiB limit. Reject duplicate keys, unknown fields, explicit nulls, invalid canonical IDs and integers outside the schemas' safe range. Responses use `Cache-Control: no-store`. TLS is mandatory outside isolated tests. Logs, metrics, receipts and status projections exclude cookies, session tokens, credentials, raw assertions and raw provider errors. Provider IDs remain opaque values behind verified bindings. Service credentials are distinct for legacy validation/revocation, credential fence reads, accepted evidence, policy-only reads and compatibility dispatch. Their authentication is independent of browser session middleware. Server enrolment restricts each operation and provider scope; payload identifiers never establish caller identity. Unavailable authority for an access decision returns `503 temporarily_unavailable`. The basic session-status read may report dependent policy/provider unavailability explicitly while Party/session authority is current; that view grants no access. ## OIDC and Party evidence The API selects the enrolled issuer, client and scopes. An exact login `Origin` selects one callback from the server's explicit allowlist. Browser input can only request an allowlisted same-origin return path. Validate decoded paths, reject protocol-relative URLs, backslashes, control characters and redirect destinations outside that allowlist. Start persists a random browser-bound login attempt, state, nonce, PKCE S256 verifier, selected callback, return path and expiry before redirecting. The token exchange uses that same callback. A short-lived HttpOnly, Secure, SameSite=Lax callback cookie supports the cross-site top-level callback; it grants no platform authority. The normal `pharos_session` remains opaque, HttpOnly, Secure and SameSite=Strict. Local HTTP development needs an explicit isolated exception. Cookie names, scope and clearing must agree across both endpoint families. The callback requires the original browser binding and unconsumed state, exactly one of `code` or `error`, and no duplicate parameters. Validate the exact enrolled issuer, audience/authorised party, signature/key, nonce, expiry, authentication age and PKCE exchange. If UserInfo is used, its subject must exactly match the ID token. An error response is not authentication evidence. Provider error text and tokens never enter redirect URLs. Callback responses use `no-store` and `Referrer-Policy: no-referrer`. Success redirects to the token-free `/auth/complete` landing document, which starts a fresh same-origin navigation to the configured return path. This preserves Strict cookie delivery across an external IdP redirect chain. Failure redirects to the fixed `/auth/error` retry page. New logins use two sequential authorisations: online authentication establishes browser SSO, then the complete enrolled scopes request offline consent. The second callback uses fresh state, nonce and PKCE challenges saved under the same attempt and expiry. Its verified issuer, subject, stable person ID and Site username must match the first identity before Party registration or session issuance. The callback cookie changes to the second state while retaining its browser binding. An initial callback may redirect to the enrolled provider again; only the completed consent callback sets `pharos_session` and returns through `/auth/complete`. Provider consent policy remains effective, and online refresh tokens are never sent to the offline-grant owner. User-initiated sign-out revokes local authority and clears browser cookies before redirecting through the issuer's discovered `end_session_endpoint`. The API sends the exact registered browser-origin return URI and client identifier. It retains no raw provider token for logout. The browser explains the provider confirmation step. Operational failures redirect to `/auth/logout-error`, whose fixed HTML page preserves cookies and offers an Origin-protected retry. The local-only `/auth/v1/logout` remains available to the retained compatibility adaptor and other enrolled non-navigation callers. Persist callback completion/checkpoints so a duplicate callback cannot exchange a consumed code again, emit another registration or resurrect an expired/revoked session. A definitive rejected or expired attempt requires fresh login. Uncertain exchange/registration/session-write outcomes require recovery; browser retry cannot invent new evidence under an existing command identity. After validation the API signs the [v3 assertion](assertion-v3.schema.json). Historical v2 assertions and accepted checkpoints retain their existing bytes. Use the schema's protected header with an enrolled Ed25519 key. Beto accepts only its enrolled API attester, key, exact OIDC issuer and v3 audience/type. `jti` equals the registration command ID; `login_attempt_id` is API-generated and correlated in request and result. `evidence_ref` is `sha256:` plus the lowercase digest of the complete compact JWS. Request retries retain every signed byte, command ID and correlation ID; transport request IDs may change. Require `auth_time <= iat < exp`, lifetime and authentication age at most five minutes, and at most 30 seconds of future clock skew. Reject duplicate, null, unknown or ambiguous header/claim values before signature/schema acceptance. Validate cryptography and enrolment as well as the JSON shape. An email must carry `email_verified: true`; username/email alone never prove account ownership. Issuer and subject remain exact strings. Claims confer no Project authority. Keep the signing key in the API authentication boundary; Beto receives public verification keys. Recheck current Party eligibility/generation before returning a recorded registration outcome and before issuing a session. A fresh login after disablement/re-enablement cannot reuse an older registration decision. ## Identity attribute inventory and recovery | Claim | Source and treatment | | --- | --- | | `iss`, `sub`, `auth_time` | Validated ID token; issuer and subject remain exact authentication bindings. | | `voperson_id` | Required for v3; accepted from the validated ID token or matching UserInfo. Conflicting values reject login. Globally unique opaque identifier within Party Governance. | | `site_username` | Optional UserInfo claim only. A qualified, issuer-enrolled Site hand-off establishes a separate `site` provider binding. Absence does not reject platform login. | | `name`, `given_name`, `family_name`, `preferred_username`, `schac_home_organization` | UserInfo takes precedence when present; validated ID-token fallback when absent. | | `email`, `email_verified` | Read together from one source; retain only the verified pair. A UserInfo email never borrows ID-token verification. | | `voperson_external_affiliation`, `entitlements` | Optional arrays of exact source strings, with absent and empty preserved. They confer no permissions or Project membership. | Recognised claims must have the declared types in both sources. Reject nulls, control characters and coercion; unknown provider extensions are ignored. Arrays contain at most 128 values of at most 2,048 bytes each. Individual limits remain subject to the complete 16 KiB signed-assertion and 32 KiB work-request limits. Required scopes are explicitly requested; access-token claims cannot supply identity attributes. GRNET currently describes Site usernames as `p[N][LLL][HHHH][Padding]`: the first initial, three surname letters, a hash of the unique identifier and a collision suffix when needed. For example, `pgfam7a3f` is an illustrative fixture for Given Family; it does not specify the hash algorithm. Preserve the exact AAI value for the downstream HPC POSIX username match. Validate type, length and control characters without enforcing this provider naming pattern, deriving a username, trimming or changing case. Native Site constraints belong to the Site adaptor and its provider qualification. An existing exact issuer/subject may adopt its first unique `voperson_id` without changing PartyId. A changed subject for an existing `voperson_id` returns `recovery_required`; it grants no session and creates no duplicate Party. Missing or changed stable identity fails. Recovery requires an operator-authorised owner command with an independently approved proof for that same stable identifier. It preserves PartyId and access state, retires the former authentication binding and advances `auth_version`, fencing previous sessions. Historical bindings cannot register a new Party. V3 issuer enrolment prevents fresh v1/v2 registration for that issuer. Historical exact identities can use those protocols until adoption; an adopted root requires v3. Default legacy installations retain their existing behaviour without v3 enrolment. This is a coordinated cutover requirement. The canonical self query retains these source observations. Consumers needing contact changes can subscribe to `PartyIdentityAttributesChanged`, which exposes only contact fields and revisions. It carries no stable identity, authentication subject, Site username, affiliation or entitlement claims. Site association is reported through the existing provider-binding events. ## Sessions and legacy operations The API stores technical session authority and exposes a non-bearer `session_ref` plus `session_generation` only to scoped internal callers. These are distinct from Party `auth_version`, root revisions and the browser bearer token. Session expiry is fixed at issuance; status/validation calls do not extend it. The runtime stores versioned v2 technical state separately and rewrites no v1 record. The runtime guide describes restart, fixed expiry and cutover behaviour. An accepted Party may receive a limited onboarding session while provider setup or AUP acceptance is pending. Every validation rechecks current Party eligibility, authentication generation, local revocation and expiry. The legacy server selects the registered operation from its route/action binding; browsers cannot choose it. | Registered legacy operation | Additional gate | | --- | --- | | `legacy.onboarding.read` | Basic self/status only; no provider credentials exposed to callers. | | `legacy.policy.read` | Verified GitLab association and required policy-read readiness; current AUP acceptance is unnecessary. | | `legacy.policy.accept` | Same prerequisites, plus Origin/CSRF protection at the browser boundary. | | `legacy.onboarding.resume` | Explicit current-session resume, with Origin/CSRF protection. | | `legacy.profile.read` | Current AUP and required GitLab readiness. | | `legacy.gitlab.request` | Current AUP, verified binding and ready credentials; existing endpoint/provider permissions still apply. | | `legacy.waldur.invitation.accept` | Current AUP, verified Waldur binding and ready Waldur association. The caller must use current Party contact evidence and a same-origin state-changing request. | `200` from validation means only the returned operation is currently allowed. The response must match the requested operation and current session fence. `401` means missing/invalid/expired/revoked session, `403` means ineligible or disallowed authority or required AUP, `409` means a pending provider/binding prerequisite, and `503` means unavailable authority. Unknown operation IDs fail. Never cache a positive decision as an independent grant. Only after an allowed decision may the legacy adaptor map the canonical Party through its verified binding to the existing GitLab user shape and retrieve the server-side token from compatibility storage. Neither internal validation nor browser status returns a provider token. A PartyId never replaces a GitLab ID. Audit all shared helpers, middleware, layouts and server actions before switching. Direct incoming `PRIVATE-TOKEN` clients require their own qualified policy and cannot bypass these browser gates. Policy-only lookup resolves the current established GitLab binding for the requested Party. Unresolved association returns `409 binding_pending`; a retired or foreign binding returns `403 forbidden`. It reads legacy acceptance without calling either API session validation or NextAuth. `state: current` requires an accepted version exactly equal to the current required version; otherwise state is `required`. Provider/read failure returns `503`, and must not erase acceptance or fabricate a policy version. The API retains this AUP gate on both route families. The private policy path also supports `GET`, using the same enrolled reader credentials and private-host checks. It accepts no query parameters or body and returns only `authority`, `required_version` and `checked_at`. This requirement read uses no Party, binding, acceptance or session lookup. SSH Access uses it after confirmed binding absence; transport and authority failures still defer revocation. The existing POST request and response shapes remain unchanged. ## Durable compatibility work The retained integration owns each work record, command receipt, desired revision, confirmed effects, recovery lease and outgoing hand-off. HTTP `202` acknowledges their durable commit, never just an in-memory task. It returns the original recorded command outcome on an exact replay; status reads return current progress. No operation permits arbitrary GitLab/Waldur URLs, token scopes or role grants. Account work has the business key `(PartyId, provider, instance, account_scope)`. The server assigns its canonical `work_id`; concurrent requests for that key converge on the same record and provider account. `expected_revision: 0` creates absent work; later changes require its current revision. A command retry preserves its ID, expected revision, generation, evidence and attributes. Changed material under the same command ID is `409 idempotency_conflict`; stale new work is `409 revision_conflict`. Fresh login uses the read-only account lookup by Party and scope to resume the same work without creating another provider account. The API keeps verified evidence privately recoverable until accepted work is terminal, with its original validation provenance. The scoped evidence resolver checks Party, authVersion, digest and enrolled provider scope; the worker compares all returned correlations and attributes with its immutable command. Unavailable or mismatched evidence cannot authorise an effect. An expired assertion cannot authorise a new login; recorded ownership evidence is separate from current Party eligibility. Recheck current Party authority before enabling effects. Identity conflicts require attention; email matching cannot merge accounts. Attribute refresh and provider progress leave authVersion unchanged. Credential work uses a separate key `(session_ref, session_generation, gitlab_binding_id)` and the complete immutable session fence. Before effects and before exposing their result, the worker checks that fence through the API's dedicated read. Resolve the binding as currently established, provider `gitlab`, owned by the fence's Party and within the enrolled instance before issuance and exposure. Unavailable authority pauses work. Tokens remain private to the compatibility store; local retrieval stops at session expiry/revocation. The retained provider's date-only expiry needs explicit revocation and reported lag. Reconcile uncertain issuance before retrying; an old result cannot confirm the newest desired revision. Revocation immediately fences local API authority and durably records cleanup. The compatibility revoke operation writes a terminal fence even when issuance has not arrived; it does not require a current session or Party/AUP provider reads. Late ensure requests/results cannot clear that fence. Cleanup succeeds only after known and uncertain credentials have been reconciled/revoked. A provider outage leaves cleanup pending while platform access remains revoked. Each provider progresses independently. `ready` means all effects for the desired revision are confirmed; `confirmed_revision <= desired_revision <= revision`. Account readiness also requires the separately committed verified binding. The private binding-reader lookup preserves the exact provider identity and requires the returned binding to belong to the requested Party and enrolled provider instance. Account work can recover an already established association after rechecking current native ownership, evidence and canonical state. Retired or foreign bindings remain blocked. This read changes no canonical authority; the retained worker checkpoints the existing ID under its work lease and revision. An account's proof is handed durably to Party Governance's existing private binding operation; provider completion never writes a Party root transaction. The [private binding ingress](../party_governance/provider_binding/README.md) verifies the retained writer's signed ownership fact and current Party identity. Account work stays pending until its independently committed binding outcome arrives. Its attester, provider scope and broker permissions require explicit enrolment. The evidence resolver above supplies AAI provenance and does not itself prove ownership of a GitLab/Waldur account. No `PolicyAccepted` integration event is emitted for legacy acceptance. Project fulfilment delegates overlapping user prerequisites to this same account writer until a qualified per-operation transfer. Project groups/access remain behind Project's port. This interface introduces no Account Provisioning context, automatic Project membership or Metallo provider lookup. ## Delivery and compatibility checks Browser status is an initial/reconciliation read; explicit resume is a user action. These contracts enable no subscriptions or automatic browser refresh. Before an interactive pending screen receives updates, declare a token-free typed Protobuf projection and server-authorised Centrifugo subscription with revision/gap handling. Browser polling is prohibited. All declarations are additive. Existing `/auth/login`, `/auth/session`, `/auth/logout`, `/auth/party/handoff`, private v1 Protobuf identities, signed assertions, receipts and stored sessions keep their meanings. Activation needs coordinated route/adaptor deployment and qualified old-session invalidation or explicit import. No request failure triggers fallback to independent NextAuth login. Run `pnpm generate:contracts`, `pnpm verify`, the schema/negative fixtures in `scripts/test_authentication_contracts.py` and API authentication wire tests. Schema fixtures test structure; protocol invariants, OIDC provider behaviour, session fences, work atomicity and browser cutover have runtime tests and separate environment qualification lanes.