Skip to content
go-make-bytesPublic

About

The fleet's OAuth 2.0 authorization server — brokers eID logins to one stable person subject and mints DPoP-bound access tokens

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

55 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

authbyte

The identity and authentication authority of the eSignature portal — the OAuth 2.0 Authorization Server (RFC 6749) the whole fleet trusts to say who a caller is. It brokers login against the Latvian eID identity providers, resolves each login to one stable person subject, and mints short-lived, DPoP sender-constrained (RFC 9449) JWT access tokens (RFC 7519) that every other service validates against this issuer's published keys.

It is the only service that talks to the upstream identity providers, and the only one that holds the token-signing key. Everything downstream is a relying party on the tokens it issues: services never authenticate users themselves — they validate a token minted here and trust its sub, loa, login_method, and cnf.jkt claims. That trust boundary is the point of the service — authentication is centralised so the signing flows, the portal API, and the audit services can stay stateless about how someone logged in.

It resolves a login to a person, not to a credential: the same human authenticating by eParaksts Mobile one day and a Web eID card the next — different upstream subjects — maps to one internal subject, keyed on the eIDAS national identity code. It also carries a login-method to signing-flow binding: the method someone logged in with determines which signing flows their token may later drive, so a signature can only be produced by a login strong enough to authorise it.

It renders one human page, its own sign-in page, shown to a client whose request names no way in (and a bundled static demo, off in production). Its surface is OAuth/OIDC-shaped for the browser SPA and its backend-for-frontend, plus a token endpoint used server-to-server by the rest of the fleet. Built on azugo.io/azugo; module github.com/go-make-bytes/authbyte.


Where it sits

authbyte is the hub of the fleet's auth. The browser SPA (portal-spa) begins login through its backend-for-frontend (portal-api); this service drives the Authorization Code flow against one configured upstream OIDC provider — the eParaksts / Entrust identity provider in this deployment, or any standard OIDC provider via the generic connector (see Configuration) — or, for eID smart cards, calls the web-eid engine's stateless validation. It resolves the login to a person in the shared PostgreSQL, keeps transient flow and session state in the shared cache (Valkey or Redis), and issues tokens signed with a key held only here. Downstream resource services (document, envelope, signflow, notification, …) never see the identity provider — they validate the issued token and, when acting for a user, obtain an on-behalf-of token back from this same /token endpoint (RFC 8693 token exchange).

flowchart LR
    SPA["portal-spa<br/>(browser)"]
    BFF["portal-api<br/>(backend-for-frontend)"]

    subgraph Fleet["auth deployment"]
        direction TB
        AC["authbyte<br/>(this service)<br/>OAuth AS · token issuer · JWKS"]
        WE["web-eid engine<br/>stateless card validation"]
        AA["access-audit<br/>GDPR access ledger"]
        RS["resource services<br/>document · envelope · signflow"]
        RD[(Valkey<br/>flow · app codes · sessions · refresh grants · jti replay)]
        PG[(PostgreSQL<br/>person + credential mapping<br/>— procedures only)]
    end

    IDP["eParaksts / Entrust IdP<br/>(external)"]
    VAULT["secret store<br/>(Vault agent)"]

    SPA -->|"login (PKCE)"| BFF
    BFF -->|"/authorize · /token · /identity (DPoP)"| AC
    AC -->|"Authorization Code + userinfo"| IDP
    AC -->|"POST /auth/validate (service token)"| WE
    AC -->|"procedures only"| PG
    AC -->|"flow · sessions · replay"| RD
    AC -.->|"GDPR access record (optional)"| AA
    AC -->|"token-signing key, client secrets"| VAULT
    RS -->|"validate token (JWKS) · token exchange"| AC
Loading

Division of labour: portal-api owns the browser session cookie and proxies the SPA's requests; authbyte owns the authentication verdict, the person mapping, and every token the fleet honours; the web-eid engine owns the cryptographic validation of a Web eID card token; access-audit owns the durable GDPR personal-data-access ledger; the eParaksts / Entrust identity provider owns the actual act of authenticating the human. The two seams that bite are the JWKS this service publishes (every relying party pins its trust to it) and the login_method claim (the signing services read it to decide whether a login may drive a given signing flow).


Supported configurations

Two knobs decide who is asked about membership: whether a membership register is wired (ROLEBYTE_URL), and — per browser client — whether the people who log in through it must belong to an organisation (membership_required on the client's registry row). Every combination below is a first-class configuration; none is a degraded form of another.

  • Standalone (default — ROLEBYTE_URL empty): every authenticated person is minted the baseline scopes of the client they signed in through (baseline_scopes on its registry row; a row that lists none mints none) and no tenant claim. Authentication is access: whoever the identity provider vouches for gets the baseline capabilities. No service account can obtain a tenant-named token here, because there is no register to hold its membership.
  • Register wired, client requires membership (ROLEBYTE_URL set; the client's row says membership_required: true, or leaves it unset — required is the default whenever a register is wired): the person is resolved from the register at every user-token issue. No membership is refused (403 err:membership:notMember — the register, not the login, grants access); one is minted — its group:level scopes and its tenant; several are the person's to choose from (below). A product whose organisations decide who belongs runs this way. The register's answer must name the person it is about (subjectKey) and be the one asked about; an answer about anyone else, or about nobody, refuses the token (502 err:upstream:unavailable) rather than mint it, and each resolve logs who was asked and what came back.
  • Register wired, client admits anyone (membership_required: false on the client's row): the client's people are minted the baseline and the register is never asked about them — a public portal in a deployment whose register exists for its machine members. Signing in and signing do not depend on the register's availability.
  • Service accounts (any configuration with a register): a client that names a tenant at client_credentials is a machine that is a member of that organisation. Its membership is checked in the register, the tenant is minted into its token, and its scopes are what the registry grant allows and the membership grants. A client that names no tenant is a platform service acting as itself: the registry alone decides, as before.

A client row that requires a membership in a deployment with no register is a configuration error: the service refuses to start rather than mint such a client's people the baseline. So is a row that lists baseline_scopes while its people need a membership (membership_required: true, or unset with a register wired): that baseline could never be minted.

Choosing an organisation

A person who belongs to several organisations and names none is issued a token that carries no tenant and no scopes — never the union, which would mint one organisation's roles while acting in another — and the token response lists the organisations in tenants. The client shows the choice and repeats the login with /authorize?tenant=<id>; the choice rides the login into the token issue, where it is checked against the person's memberships (an organisation they do not belong to is refused 403 err:membership:notMember) and the chosen organisation's token is minted. The choice is remembered by the session, so a refresh re-resolves the same organisation — and a revoked membership still dies at the next refresh.

The register boundary (ScopeResolver contract)

The seam between this authorization server and the membership register is one interface, consulted at every token issue that needs a membership (first issue and refresh of a person whose client requires one; every tenant-named service issue). The register and this service ship separately, so this boundary is a published contract:

  • Question: a typed subject key — a person's platform subject (sub:<person id>, the identity store's stable id, equal to the token's own sub) or a service account's client id (svc:<name>, the same name it authenticates with). No identity code travels into the register.
  • How it is asked: at the register's sign-in doors, on its sign-in listener (POST /api/v1/sign-in/claims, then POST /api/v1/sign-in/memberships), which only this service can reach. The request names nobody. Each call carries a token this service mints in its own process for that call alone: the subject being signed in as sub, this service's client id (AUDIT_CLIENT_ID) as the acting client (act), the door's one scope, bound to the outbound client's DPoP key. The register answers for the token's subject and nobody else. No token endpoint and no registry grant are involved.
  • The answer is checked: it names the subject it is about, and an answer about anyone else mints nothing, fails the issue closed (502) and raises an authz.denied security event (kind: sign-in), which a healthy deployment never produces.
  • Answer: the subject's active memberships — each organisation with the group:level scopes the subject's roles grant there. The register attaches any pending invitation first, so a subject's very first token already carries the invited roles.
  • The admission door, for a login through an organisation's own directory. Such a session carries the issuer it came through. When the person has just signed in and holds no membership, this service offers them to the register's admission door (POST /api/v1/sign-in/directory-admissions, the same kind of token at the membership:claim scope, the person in the token and only the issuer and name in the body): the tenant that attached that issuer as its directory admits them as an active member with no grants, and they are resolved again — a member who holds nothing until an administrator assigns a role. Somebody the provider calls a guest in its directory is not offered, a person already holding a membership is not offered again, and an issuer no tenant attached admits nobody — each of those is the plain 403 below. A refresh is never offered: a session that resolves to no membership was removed, or the register no longer holds it, and it gets the same 403; admission waits for the person's next sign-in. The door unreachable fails the issue closed like the resolve.
  • Refusals are this service's to make from that answer: no membership → 403 err:membership:notMember; a named organisation the subject is not a member of → the same 403; several memberships and none named → the choice above, never a guess.
  • Unreachable register fails the issue closed — 502 err:upstream:unavailable. An empty-scope or guessed-scope token is never minted.
  • Compatibility: the contract evolves additively. A register that answers this contract keeps working across releases of either side; a change that would refuse tokens this contract allows is a breaking change and is versioned, not slipped in.

Every case above is pinned by a test (routes/token_rolebyte_test.go), including each configuration's happy path.


HTTP surface

Endpoints are registered in routes/router.go. The two optional surfaces — Web eID card login and the dev/demo helpers — are registered only when configured, so an unconfigured capability is absent, not merely disabled.

Method + path Purpose Auth
GET /authorize, POST /authorize Begin user login, the request in the query or as a form (Authorization Code + PKCE, RFC 7636, S256 only: another method or none is answered at the client's redirect_uri with error=invalid_request). Validates redirect_uri against the client's redirect_uris, saves flow state, redirects to the identity provider. Optional acr_values: the methods the client accepts, in order; a value naming no method the provider offers and the client's row allows (methods) is answered with error=unmet_authentication_requirements, and a sign-in the provider completes with another method gets the same answer instead of a code. A POST that arrives without the browser's binding cookie is answered 303 to the same request as a GET, which carries it. With none named, the browser is shown this service's sign-in page (below), listing the methods the row allows that the provider offers, or saying none is set up. An unknown client or an unregistered redirect_uri gets a page saying the sign-in cannot start when the request asks for HTML, and the error answer otherwise; never a redirect. Optional tenant=<id>: the organisation a person with several memberships chooses to act under (checked at the token issue) anonymous
POST /authorize/choice The sign-in page's answer: the method the person picked. Its address carries the client's request, checked again as /authorize checks it; it needs the browser's binding cookie, the page's token and an Origin equal to BASE_URL's (a default port written there is left out, as browsers leave it out), and a method the page offered; it then sends the browser to the provider (303) anonymous (the page's browser)
GET /signin/language Keeps the language the person picked (lang) and shows the page again (next, this service's own /authorize only, rebuilt from its parsed query) anonymous
GET /signin/{name} The page's stylesheet and typeface, and the operator's mark anonymous
GET /callback Handle the identity-provider redirect: exchange the code, read claims, resolve the person, establish a session, mint a single-use app authorization code. A program passing several providers serves one such path per provider, each accepting only the answers to sign-ins sent to its own provider state-verified (path configurable)
GET /logout The browser's leg of a sign-out (client_id, redirect_uri, optional login_method): send the browser to redirect_uri, which must be on the client's post_logout_redirect_uris (a sign-in return is not a sign-out landing). For a method listed as federated — the eParaksts profile's methods, or OIDC_UPSTREAM_METHODS_FEDERATED for a generic provider — it goes through the identity provider's logout first, so the session the provider keeps in the browser ends too; any other method signs out locally and leaves the provider's session alone. It takes no value naming a session: the client ends its own with POST /revoke first. The browser's single sign-on cookie, when it holds one, ends that shared session and every session signed in under it, and is cleared anonymous (redirect_uri allowlisted)
POST /token OAuth token endpoint — authorization_code | client_credentials (optional tenant=<id>: a service account acting for the organisation it is a member of) | refresh_token (with the client_id the token was issued to) | urn:ietf:params:oauth:grant-type:token-exchange. Every hop requires a valid DPoP proof. A confidential client proves itself at every grant with its secret, by HTTP Basic (client_secret_basic) or in the form (client_secret_post), never both in one request, and at the code exchange before the code is spent; a public client sends none PKCE / client secret / refresh token + client + session key / subject token — all + DPoP
POST /revoke End a session server to server ([RFC 7009]): token (the refresh token), client_id, optional token_type_hint (ignored). 200 when ended or when the token is unknown; 401 for another client's token, a proof of another key, or a confidential client without its secret; 503 when the store cannot be read the session's own client (its secret, when confidential) + its DPoP key
GET /identity The internal identity plus loa, login_method and, to a token of a client whose row says signing: true, the signing flows that method permits. A token for any enabled sign-in client's audience is accepted valid user token (DPoP)
POST /identity/persons Register a person before their first login: the identity code (any spelling; stored canonical) and the name create the person row with a stable subject and no credential — the subject an administrator then invites them to a membership register under. Idempotent on the code: 201 created, 200 already known (names filled only where the row has none); a code naming no country or an unknown identity type is 422 err:identity:invalid (never echoed) valid token carrying identity:admin (DPoP)
POST /identity/persons/lines Read, for up to 500 people at once ({"subjects": ["sub:<id>", …]}), whether each can sign in and what is known about how: the email a directory sign-in left (only when one is kept) and, for a person holding a national identity code, its country and displayed length — never the code. A person the platform does not know is left out. More than 500, an empty or non-list body or a malformed subject is 422 err:identity:invalid (the values sent are never repeated). Answered Cache-Control: no-store a service acting as itself (its own token: subject svc:…, naming no organisation and no actor) carrying identity:lines — a person's token, a token exchanged to act for someone and a service account's token for an organisation never read it, even holding the scope (DPoP)
GET /identity/connections The AI apps connected as the caller's own assistants in the workspace their token names: {"assistants": [{"key", "name", "connections": [{"id", "app", "connectedAt", "lastUsed"}]}]}, an assistant with none listed with none, newest connection first, lastUsed null until a call went through. 502 err:upstream:unavailable when the register cannot answer which assistants are theirs; no-store a person's own token naming a workspace (no actor, not an AI app's, not a service's): otherwise 403 err:connection:forbidden (DPoP)
DELETE /identity/connections/{id} Sign out one of the caller's own connected apps by its public name: its session and refresh token end, its app's next exchange is refused at once, the person's others go on; recorded as a security event naming the doer. 204; 404 err:connection:not_found for a name that is not theirs, or not live as above
GET /identity/assistants/{assistant}/connections The AI apps connected as one assistant of the workspace the token names, any person's: {"connections": [{"id", "app", "connectedAt", "lastUsed"}]}. Another workspace's assistant reads as having none; a key not shaped svc:assistant-<ULID> is 404 a person's own token holding the register's administering level, membership:admin; otherwise 403 (DPoP)
DELETE /identity/assistants/{assistant}/connections/{id} Sign out one app connected as the assistant, as the person's own sign-out does, the administrator recorded as the doer. 204; 404 when it is not that assistant's live connection in this workspace as above
DELETE /identity/assistants/{assistant}/connections Sign out every app connected as the assistant, at once (what its removal asks first). 204, with none to end as well as above
GET /identity/persons/{personSub}/code Read one person's national identity code the way a person is shown it, with its country: {"code": "…", "country": "LV"}. A person not known, or holding no code, is 404 err:identity:not_found; a malformed id is 422 err:identity:invalid. Each code answered is recorded with the access audit as a routine read of the person by the calling service (identity.read, marked identityCode, never the code); the caller records the disclosure it makes onward. Answered Cache-Control: no-store a service acting as itself carrying identity:code, refused as the lines read refuses — a grant for identity:lines never reaches a code (DPoP)
POST /authorize/card The sign-in page's card answer: the card signed the challenge the page was shown with. Its address carries the client's request, checked again; it needs the browser's binding cookie, the page's token, an Origin equal to BASE_URL's and a challenge that browser was given; the engine checks the token, and the browser goes back to the client with a code (303), or to the page anonymous (the page's browser) — only if WEBEID_ENGINE_URL set
GET /.well-known/jwks.json Signing public keys (active + retired-within-window) anonymous
GET /.well-known/openid-configuration Discovery metadata (endpoints, supported grants, S256, ES256) anonymous
GET /healthz Liveness — 200 whenever the process is up anonymous
GET /readyz Readiness (fail-closed) — 503 when the session cache or PostgreSQL is unreachable, so the pod leaves rotation anonymous
GET /demo/{path} Static demo SPA, served same-origin so the browser can read the DPoP-Nonce header only if DEMO_DIR set — development

The /metrics VictoriaMetrics registry and standard tracing/logging are provided by the shared platform base (go-platform-kit), gated by METRICS_ENABLED; there are no service-specific custom metrics.


The sign-in page

A request to /authorize that names no way in (acr_values absent) is shown this service's own page: Sign in to {client}, one button per method the client's registration lists (methods, in that order, the first the main one) that this deployment's provider can start, each with a one-line hint, in English or Latvian. A request that names a way skips it, and prompt=none never shows it. A client allowed no such method is told so on the page; the card joins the page when it is read there.

  • Tied to the browser. Every /authorize sets an http-only cookie, __Host-authbyte_signin (the plain name only on a development server over plain HTTP), holding a random value that lives ten minutes and is kept across sign-ins, so two tabs both finish. Each flow keeps its hash; the page's choice and the provider's return (code or error) are refused without it, before the provider's code is exchanged. Every sign-in has one, so a flow with none completes nowhere.
  • The choice is a form post with a token derived from that cookie and an Origin that must be this service's own (BASE_URL); a method the page did not offer is refused. A choice whose cookie has run out shows the page again, saying the sign-in took too long.
  • A cancel or a provider failure of a sign-in begun on the page brings the person back to the page with what happened, once; a sign-in whose client named the way is answered at the client, as before.
  • The card is read on the page where the deployment reads cards (WEBEID_ENGINE_URL) and the client's row lists webEid, in the row's order, as Web eID; a request naming webEid in acr_values is shown the page with the ways it names. The page is shown with a challenge tied to the browser; the page's script (/signin/card.js) has the Web eID library (/signin/web-eid.js, 2.1.0) ask the card to sign it and posts the answer to /authorize/card. The engine's WEBEID_ORIGIN names this service's own origin. Missing card software, a card that does not complete, and a browser running no script each get their message on the page.
  • The card through eParaksts' plugin (legacyEid, where the deployment names it) is a provider method like the others: its button, eParaksts eID, sends the person to eParaksts' page, where eParaksts' own plugin reads the card. Nothing of it runs on this page.
  • Headers: Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; form-action 'self' <the provider's authorization origin>; frame-ancestors 'none'; base-uri 'none', X-Frame-Options: DENY, Cross-Origin-Opener-Policy: same-origin (a window that opened the page cannot reach it, so it cannot post a forged answer to the card's library), Cache-Control: no-store, Referrer-Policy: same-origin (the page's address goes to no other host; no-referrer would make browsers send Origin: null on the page's own form, which the choice refuses), X-Content-Type-Options: nosniff. The page runs no script but this service's own files (the card's) and loads nothing from another host.
  • Language: the person's own pick (kept a year in __Host-authbyte_lang), then the client's ui_locales, then Accept-Language, then English; the provider is asked in the same language.
  • Rate limit: /authorize, the choice and the card's answer allow SIGN_IN_RATE_LIMIT requests per client address per SIGN_IN_RATE_WINDOW (60 a minute), counted in the application cache; the next answers 429 with Retry-After. The provider's return is not limited. An office behind one address that signs in more people than that in a window needs a higher number.

Sign-in page settings

Env Default Meaning
SIGN_IN_OPERATOR_NAME — (⇒ no foot line) Who runs this sign-in, named at the page's foot
SIGN_IN_OPERATOR_MARK_FILE — An SVG shown beside it; named but unreadable stops the start
SIGN_IN_RATE_LIMIT 60 Sign-in requests one client address may make per window; 0 turns the limit off
SIGN_IN_RATE_WINDOW 1m The window

Single sign-on

Every completed sign-in, by the card on the page or through the provider, makes the browser's shared session: an http-only cookie __Host-authbyte_sso (the plain name only in development over HTTP), Secure, SameSite=Lax, for the session's whole life, naming a record of the person and each way they signed in with. The cache keeps only the hash of the cookie's secret, and the secret is replaced at every sign-in.

A client whose registration says single_sign_on: true is answered from it with no page: /authorize sends the browser straight back with a code when the shared session fits the request, and otherwise goes on as without one.

The request is answered from the shared session when Source
it has no prompt=login or prompt=select_account, and no max_age=0; a max_age covers the way's auth_time OIDC Core §3.1.2.1, §3.1.2.3
a way the person signed in with is one the client allows, and one acr_values names when it names any (the most recent such way) this service's method names
the client's tokens carry no identity code, or the person has one the client's claims
the client requires no membership, or the register already holds one for the person (read only; no admission) the client's membership_required

The answer is a new session: its own refresh token and sid; its ID token states the way it rests on (acr, amr) and that sign-in's auth_time; it ends when that way ends, never later. No tenant is kept on the shared session: an organisation is chosen per session, at its token issue. prompt=none is answered the same way, or login_required at the client.

  • One person per browser. Another person signing in in the same browser ends the earlier shared session and every session signed in under it; their refresh tokens are refused from then on. The same client signing in again in a browser ends its own earlier session there.
  • Signing out of one app signs out of every app. POST /revoke of a session ends the shared session it was signed in under and, at once, every session signed in under that, of every client, single sign-on or not; GET /logout does the same for the shared session its cookie names and clears the cookie. Their refresh tokens are refused from that moment, so an app open elsewhere ends at its next renewal, at the latest when its access token ends (USER_TOKEN_TTL); until then the access token it holds still works. The revoke does not say why the app revoked, so a session an app discards (go-authbyte does after a failed ID token check) ends the others too. An app answered from the shared session while it is being ended is not answered: it gets the page. Nothing is sent to the apps themselves yet (no back-channel sign-out).
  • A signing client cannot be single sign-on (the registry refuses both on one row): a signing session's identities are read from the provider at a sign-in.
  • Security events: kind shared_session_created, shared_session_reused (with the client), shared_session_refused (with the client and why) and shared_session_ended (with why, and sessions: how many sessions ended with it).

Architecture

One application object (App in app.go) wires every dependency once at startup and fails closed on misconfiguration — a missing token-signing key without the explicit development opt-in stops the process from starting, as does an unparseable client registry (an unknown key included, so a misspelling cannot load as an absent setting) or an invalid audit configuration. Cross-cutting concerns (structured logging + redaction, tracing, correlation) are installed exactly once by the platform library before any route is registered; nothing re-registers them.

flowchart TB
    subgraph App["App (app.go) — built once by New() → init()"]
        direction TB
        Init["platform.Setup → keys → issuer → registry →<br/>eParaksts adapter → identity resolver + binding →<br/>DPoP nonce → cache (session + replay) → Postgres →<br/>auth-client → audit (sec-events + optional GDPR) →<br/>web-eid adapter (optional)"]
    end

    subgraph Routes["routes/ — HTTP handlers"]
        AZ["authorize · callback · logout"]
        TK["token (4 grants) · revoke"]
        WB["sign-in page · the card's answer"]
        ID["identity · jwks · discovery · readyz"]
    end

    subgraph Comp["composed packages"]
        KEYS["keys<br/>ES256 signer + JWKS + rotation"]
        ISS["issuer<br/>user · service · delegated tokens"]
        REG["registry<br/>client ↔ audience ↔ scope"]
        IDN["identity<br/>acr/amr resolver + flow binding"]
        EPA["eparaksts<br/>Entrust OIDC adapter"]
        WEB["webeid<br/>engine validate adapter"]
        AUD["audit<br/>sec-events + gdpr façade"]
    end

    subgraph State["state"]
        RD[(Valkey<br/>flow · code · session · refresh · jti)]
        PG[(PostgreSQL<br/>person mapping — procedures only)]
    end

    Routes --> App
    TK --> ISS & REG
    AZ --> EPA & IDN
    WB --> WEB & IDN
    ID --> IDN
    App --> KEYS & ISS & REG & IDN & EPA & WEB & AUD
    App --> RD & PG
    ISS --> KEYS
Loading

Two libraries carry the reusable auth primitives so they are identical across every consumer: go-authbyte (DPoP verification, server nonce, jti replay, JWK thumbprints, the auth-client that guards /identity and mints outbound tokens) and the platform emitters (go-sec-events, go-gdpr-audit). The eparaksts package is the only code coupled to the concrete upstream identity provider — a new identity source is an alternate adapter behind the same identity.Resolver.


Login and token lifecycle

The eParaksts redirect flow, from the SPA's PKCE challenge to a DPoP-bound user token. A card sign-in starts the same way at /authorize and is read on this service's page: /authorize/card replaces /callback, and the engine call replaces the identity-provider round-trip.

sequenceDiagram
    participant SPA as portal-spa / portal-api
    participant AC as authbyte
    participant IDP as eParaksts IdP
    participant RD as Valkey
    participant PG as PostgreSQL

    SPA->>AC: GET /authorize (PKCE challenge, client_id, redirect_uri)
    AC->>AC: validate redirect_uri against registry allowlist
    AC->>RD: SET flows:{state} (PKCE + client + redirect, TTL 10m)
    AC-->>SPA: 302 → IdP authorize URL
    SPA->>IDP: authenticate (eParaksts Mobile / eID Scan)
    IDP-->>AC: GET /callback?code&state

    AC->>RD: GETDEL flows:{state} (single use)
    AC->>IDP: exchange code → tokens (id_token verified against the provider's keys when it publishes any) → userinfo
    AC->>AC: resolve acr+amr (+acrs) → login_method + loa
    alt method not permitted via IdP (unrecognized → eid, or sc_plugin → legacyEid where not named)
        AC-->>SPA: 403
    else permitted (mobileid / mobile-eid / sc_plugin where named / upstream)
        AC->>PG: identity.upsert (person keyed on the identity code <br/> a codeless directory login by its credential)
        AC-->>AC: GDPR access record (routine, fail-open)
        AC->>RD: SET sessions:{sid} NX (session, lifetime from now, SESSION_TTL)
        AC->>RD: SET codes:{appCode} (bound to session + PKCE, TTL 60s)
        AC-->>SPA: 302 → redirect_uri?code&state
    end

    SPA->>AC: POST /token (grant=authorization_code, DPoP proof)
    AC-->>SPA: 401 use_dpop_nonce + DPoP-Nonce (first hop only)
    SPA->>AC: POST /token (+ server nonce, code_verifier)
    AC->>AC: verify DPoP + nonce + jti replay
    AC->>RD: GETDEL codes:{appCode}<br/> verify PKCE + client/redirect echo
    AC->>RD: SET refresh:{hash of a new refresh token} (session id + client)
    AC->>RD: SET sessions:{sid} XX (bound to the DPoP thumbprint, the session's end kept)
    AC->>AC: issuer.IssueUser (cnf.jkt = thumbprint)
    AC-->>SPA: {access_token (DPoP), refresh_token, capabilities?}
Loading

Login-time signing capabilities. When the sign-in comes through a client whose row says signing: true and the upstream's userinfo carries a sign-identity catalog (the eParaksts profile requests the sign-identity profile scope by default), the login also captures the session's signing capabilities: the signing identity the login method uses, its certificate, the paired authentication certificate, and the organisation seals the person may sign with (id + display label + certificate). A Web eID card login contributes the card's own authentication certificate instead (seal availability stays unknown there). The object is stored on the session and returned once, on the authorization_code grant — a refresh is not a new identification and never carries it. Everything about it is best-effort and optional: a failed fetch leaves fields out, absent capabilities mean "unknown" rather than "none" (seals_known marks a read catalog, so an empty seal list is authoritative), and a signing service falls back to resolving identities itself when fields are missing. Certificates carry personal data: they are never logged and die with the session. A sign-in through any other client fetches and keeps none of it.

The refresh token is a value of its own. The first code exchange of a session answers a new random refresh_token; the session's internal id never leaves the service, and the store keeps the token only as a hash, under which it records the session and the client it was issued to. A refresh (grant_type=refresh_token, refresh_token, client_id) re-issues a user token only for the client the token was issued to and only when the caller presents the same DPoP key the session was bound to at the code exchange, so a refresh token taken from its client is useless. Tokens are not rotated: two refreshes of one session at the same moment both succeed. A session lives SESSION_TTL (12h) from its sign-in and nothing saved on it later extends that; at its end every refresh is refused. A refresh token that is gone, another client's, or whose session has ended is refused 401. A session store that cannot be read answers 503 err:upstream:unavailable, because it says nothing about the session: a caller that ends its own session on a refusal keeps it through an outage here.

Sign-out runs in two legs. The client first ends the session at this service, server to server: POST /revoke with token (the refresh token), client_id and a DPoP proof of the session's key ([RFC 7009]). The session and its refresh token are deleted and the ending is recorded as a security event naming the person. The right token from another client, or without the session's key, is refused 401 and the session goes on; a token this service does not hold is answered 200, because there is nothing to end ([RFC 7009 §2.2]). A session signed in under the browser's shared session ends that and every other session under it too (single sign-on, above). Then the client sends the browser to GET /logout?client_id=…&redirect_uri=…&login_method=…, which carries no value naming the session: it ends the shared session its cookie names, if any, and decides whether the browser passes through the identity provider's logout first.


Identity resolution and the login-method binding

The identity package is the anti-corruption layer between the identity provider's (in-flux) authentication claims and the platform's stable model. Interpret scans both acr and amr against two explicit vocabularies (longest token wins), so it does not matter which claim the provider currently carries the method or level in — resolving stays correct as the upstream moves signals between the two claims; the only change a move needs is data, not control flow.

Login method (login_method) Recognised token Assurance Permitted signing flows Status
webEid (validated by the web-eid engine) high webEid, cscEidPlugin permitted (card login)
eidScan mobile-eid high eidScan, cscEidScan permitted
eparakstsMobile mobileid | smart_id | cloud high eparakstsMobile, eparakstsMobileEseal permitted
legacyEid sc_plugin | smartcard high legacyEid, cscEidPlugin permitted only where the deployment names it among the eParaksts methods (the eID card read on eParaksts' page by eParaksts' own browser plugin)
eid anything unrecognized — none rejected — the fail-closed sentinel

A card login permits the CSC signing flow that reads the card the same way the login did — the card in a reader for Web eID and for the eParaksts plugin, read by a phone for eID Scan — and never the other card route; a Web eID login never permits legacyEid, nor a legacyEid login webEid. The login_method value is one camelCase literal shared by name with the signing service, so a login and the signature it authorises correlate on a single token. The binding fails closed: an unknown or empty method permits nothing. The eParaksts profile allows eparakstsMobile and eidScan; legacyEid is on where OIDC_UPSTREAM_METHODS_ALLOWED names it (eparakstsMobile,eidScan,legacyEid), and the callback refuses with 403 any method the deployment did not name, even if the identity provider's page offered it. Assurance-level and method vocabularies can be overridden per environment (LOA_POLICY) once production's exact acr values are confirmed.

No step-up. A session's login method and assurance are fixed by its sign-in, and nothing raises them later. A client that needs another proof, such as a signing step that needs a certificate the session did not capture, starts a fresh sign-in naming the way it wants (acr_values); that makes a new session. id_token_hint stays refused.


Tokens

All tokens are ES256 JWTs (RFC 7519) minted in issuer/. Access tokens are typed at+jwt (RFC 9068), DPoP sender-constrained via the cnf.jkt confirmation claim (RFC 9449), and scoped to a single audience; a token exchange takes only an access token as its subject.

ID tokens (OpenID Connect Core 1.0). A sign-in whose /authorize asked scope=openid gets an ID token at its code exchange, typed JWT and unbound: iss, sub, aud (the client alone, as a string), iat, exp, the client's nonce when it sent one, auth_time when it is known (a card sign-in, a provider that states it, or a sign-in the provider was made to repeat by prompt=login or max_age), acr (the method met, in this server's method names), amr (registered RFC 8176 means only; a card's are hwk, mfa, pin, sc) and sid, a public session name that is neither the refresh token nor the session's key. Nothing personal beyond the subject, and no azp. Refresh, client credentials and token exchange answer none. ES256 only: the standard asks an OpenID provider to support RS256 too; this server holds one EC key, and every client it serves checks ES256. A sign-in client's client_id may not also be an audience a door accepts, or its ID tokens would be accepted there.

What /authorize reads beyond OAuth. acr_values names methods in this server's own names (a provider's code is refused) and the provider is sent its own code for each. prompt=none is answered from the browser's shared session when it fits (see Single sign-on), else login_required; consent answers consent_required; login and select_account are never answered from the shared session and reach the provider. A max_age the shared session cannot meet makes the provider sign the person in again and refuses a sign-in older than it; a provider time from before the request refuses a sign-in it was made to repeat. request, request_uri and id_token_hint are refused. Every return to the client through the browser, code or error, carries iss (RFC 9207); the issuer is AUTH_ISSUER_URL without a trailing slash, the same string everywhere. Token, identity and card-login answers carry Cache-Control: no-store.

Token Grant Subject Carries
User authorization_code / refresh_token internal person subject aud (the sign-in client's own audience), client_id (that client, [RFC 9068 §2.2]), scope, loa, login_method, the name parts and serial_number only as the client's row lists them in claims, tenant (membership deployments only), cnf.jkt
Service client_credentials client id client_id, scope, cnf.jkt — scoped to one audience by the registry; plus tenant when the client named the organisation it is a member of (a service account)
Delegated token-exchange (RFC 8693) the end user named in the subject token scope, loa, login_method, serial_number, tenant (carried forward), act (acting client), cnf.jkt
AI app authorization_code / refresh_token of a kind: ai_app client the person aud (the app's one resource), client_id, scope (the boxes it asked for that the person and the chosen assistant both hold), tenant (where the assistant is the person's), act ({sub: svc:assistant-<ULID>}), login_method assistant, loa low; no cnf, token_type Bearer, lifetime APP_TOKEN_TTL

AI apps ([MCP 2026-07-28 basic/authorization]). An AI app's /authorize names its one resource (RFC 8707), refused invalid_target otherwise, and asks only for boxes from its row (invalid_scope); it chooses no tenant. After the person's usual sign-in, a page lists the person's own assistants (the register's /api/v1/sign-in/assistants), each by its name and its workspace; one is still asked, none is a refusal in words and no code. The choice is posted once, from this page, in the browser the sign-in began in, with the page's token; the assistant must still be the person's as the register answers then. The app's code exchange and refreshes take no DPoP proof and answer RFC 6749 §5.2 errors (invalid_grant, invalid_target, invalid_request). Its refresh token is good once: each refresh answers a new one, and one used again ends that connection. Every refresh asks the register again: the assistant removed or given to another person, or the person gone from the workspace, ends the connection; a box the assistant lost is gone from the next token. A connection is a session of its own, not the browser's shared one, lasting at most SESSION_TTL; the app ends it at /revoke with its refresh token and no key, which ends nothing else. A connection is named and listed: when the app redeems its code the connection gets a public name of its own (never its session's key or a token), its time of connecting, and a place in its assistant's index (per workspace and assistant, until the connection's end); each app token states that name as its sid. It stops at once when it ends: an exchange of an AI app's token is refused, 401 err:connection:ended, unless the connection its sid names is live, still the token's person's, app's and assistant's, though the token itself has not expired; a live one is marked used, at most once a minute. A person lists the apps connected as their own assistants and signs one out; a workspace's administrator does the same for an assistant's, and ends them all when it is removed (the /identity/connections routes, above). A token exchanged from an AI app's token (its login_method is assistant) carries no box (service/feature:act) the app token lacks, at every hop, whatever the exchanging client's grant allows. A level (group:level) of the exchanging client's own grant passes only where its grant toward that audience names it under assistant_levels, so a service can still do its own work for the person, such as naming people at the register, while a level of authority never reaches an assistant unless a deployment names it; a level the token being exchanged carries is not enough. login_method and loa are carried forward, so nothing that asks for a real sign-in accepts it.

The register key. With a register wired, the membership lookup at token issue asks by the person's typed platform subject — sub: + the token's own sub — and by the client id (svc:…) for a service account. No separate claim carries the key: every consumer derives it from sub the same way, so a token and an invitation written from the identity store's answer meet on the same bytes. The identity code (serial_number) stays on the token for the signing side and is no register key.

With a register wired (ROLEBYTE_URL set), the tokens of people whose client requires a membership carry the tenant claim — the organisation of their single membership, or the one they chose — resolved at every issue alongside the scopes; a service account's token carries the tenant it named at issue. Multi-tenant resource services scope every operation by the token's tenant, never by request data, and token exchange carries it forward so delegation cannot strip it. Standalone deployments, and the people of a client that admits anyone, are minted no tenant claim.

Token exchange lets a confidential client obtain an on-behalf-of token toward another service: the presented subject token must be one this issuer minted and still valid, and issued for the client itself or for an audience the client's registry row lists under act_from (a product's edge lists the product's user audience, so one product's edge cannot act on a token minted for another, nor on a token delegated to a different service); a service subject may be acted for only when its token carries a tenant — a service account's token, which nothing but a verified membership mints — while a platform service acting as itself (no tenant) cannot be impersonated, and the refusal is recorded as an authorization denial; the minted token names the user as sub (so downstream owner-filtering is identical to a direct user call) while recording the acting client in the act chain, and carries login_method + loa forward so the login-to-signing binding still applies downstream. The exchange audience and scopes are authorised against the same registry grant matrix as client_credentials.


State and data model

The service never touches database tables. PostgreSQL access in store/postgres.go goes exclusively through SECURITY DEFINER stored procedures called with a uniform JSONB envelope (CALL proc($1::jsonb, NULL::jsonb) → po_data); the service connects with an EXECUTE-only role (authbyte_public) that has no direct table grants. The schema and the procedure logic are owned by the platform's separate database migration repo (one authored home per schema, shipped as one migration image) — this package only knows procedure names (identity.upsert, identity.register, identity.get). A procedure that fails after a write re-raises a structured error (SQLSTATE P0001) whose message is the same envelope, so a validation failure and a post-write rollback surface identically.

The person is keyed on the eIDAS national identity code (serial_number), in one canonical spelling — the identity type, the country, a hyphen, and the national code with its separators removed (PNOLV-XXXXXXXXXXX). Every spelling a card or a provider writes is reduced to it before it is stored or compared, so the same human across different auth methods — different upstream subjects, and different ways of writing the same code — resolves to one internal subject. The country is never guessed: a code that names one keeps it, otherwise it comes from the card certificate's own country attribute — the nearest fact about a person there is, since they are holding the card — and a code that names no country, from a source that supplies none, refuses the login. identity.upsert reports whether the person was created on this call (first-ever login) so the caller emits the correct GDPR event (created vs updated); linking a new method to a known person is an update. identity.register creates the same row for a person who has not logged in yet — canonical code and name, no credential — so an invitee or a record-only worker has a platform subject from the first act; their first login matches the same canonical code and lands on that row. A person who signs in through an organisation's directory carries no code at all: the identity store resolves them by their credential handle (the provider's durable identifier) and creates them without one; a codeless person and the same human's card login are two persons until linked by a deliberate act. A credential is keyed by its provider and its handle, since a handle is unique only at the provider that issued it (OpenID Connect Core §5.7): the same handle from two providers is two credentials and, without a code, two people. The empty provider is the deployment's first: every credential stored before a deployment had several providers is kept under it, the one-provider server writes it, and with several providers the entry marked HoldsStored (and any entry of that same provider) does; every other entry writes its provider's issuer, or its authority where it has none. The card's sign-ins are kept under a provider of their own, webeid, so no provider's handle can name a card holder; no upstream may name it as its issuer. A name the provider does not send leaves the stored name as it is.

The shared cache (Valkey or Redis, CACHE_*) holds only short-lived auth-flow and session state (session/), every key TTL-bounded. Keys are <CACHE_KEY_PREFIX>:<instance>:<key>; the cache serializes every value, so nothing stored shares memory with the request it came from:

Instance Value TTL Notes
flows:{state} in-progress login (PKCE, client, redirect, the browser's binding, Web eID nonce) 10m consumed with GETDEL (atomic single use)
codes:{appCode} app authorization code → session + PKCE 60s consumed with GETDEL (replay-safe)
sessions:{sid} user session (subject, loa, method, scopes, DPoP thumbprint, the end of its lifetime, signing capabilities) SESSION_TTL from the sign-in created with SET … NX; every later save is SET … XX for what is left of its lifetime, so a session ended meanwhile stays ended and nothing extends it. sid never leaves the service. Capabilities hold identity certificates (personal data) — session-bound by design, never logged
refresh:{hash} the session id and the client a refresh token was issued to the session's remaining lifetime keyed by the SHA-256 of the token; the token itself is never stored. Deleted at /revoke
dpop-proofs:{jti} seen DPoP proof ids proof max-age + leeway SET … NX: one-time-use across every instance
shared:{id} a browser's shared session: the person (subject, names, identity code, directory), each way they signed in with (method, level, auth_time, amr, its end), the hash of the cookie's secret until its last way ends (SESSION_TTL after that way's sign-in) created with SET … NX; a sign-in by the same person rewrites it with SET … XX; no tenant is kept on it
shared-apps:{id} the sessions signed in under a shared session, by client the shared session's end written by one script that answers the client's earlier session, which is then ended; deleted with the shared session

A cache span in a trace is named by its operation and instance (GET sessions) and never by its key, because every key here is a session id, a token hash, a code, a login state or a proof id. Sessions and flows held under the earlier key layout (flow:, code:, sess:) are not read: taking this version signs everyone out once.


Keys

A single ES256 token-signing key, managed in keys/. In production it is loaded as a PEM from the secret store (TOKEN_SIGNING_KEY); with no key configured the service refuses to start unless ALLOW_EPHEMERAL_SIGNING_KEY=true (development only — an ephemeral key cannot validate tokens across restarts or pods and is a silent security downgrade). The key id (kid) is the JWK thumbprint of the public key.

Rotation is overlapping: Rotate installs a new active key and retains the previous one in the JWKS until tokens signed under it expire, so verifiers (including this service verifying a token-exchange subject token) never see a gap. When SIGNING_KEY_RELOAD_INTERVAL > 0, a background reloader polls the key secret and rotates on change with no redeploy; an unchanged reload is a no-op. Public keys are published at /.well-known/jwks.json (active + retired-within-window).

# token-signing key — EC P-256 private key, PEM.
# Point TOKEN_SIGNING_KEY_FILE at this file (or set TOKEN_SIGNING_KEY directly).
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out token-signing.key.pem

Configuration

Non-secret values come from environment variables; secrets are loaded from the secret store (Vault agent) via the <NAME>_FILE convention, and an explicit plain env var still overrides the file. Standard fleet env (SERVICE_NAME, ENVIRONMENT, SERVER_URLS, LOG_*, METRICS_ENABLED, OTEL_*, BROKER_*) is inherited from the shared platform base.

Issuer

Env Default Meaning
AUTH_ISSUER_URL — (required) This service's iss / public base
AUTH_JWKS_URL — (⇒ derived from issuer) Internal JWKS URL override, when the public issuer is unreachable in-cluster
BASE_URL http://localhost:8080 Externally reachable root, used to build the absolute callback URI

Upstream identity provider (the generic OIDC connector)

Exactly one upstream provider is configured per deployment, one of two ways. Generic — any standard OIDC provider (Keycloak, Microsoft Entra ID, Google, an enterprise IdP): set OIDC_UPSTREAM_AUTHORITY_URL and the endpoints are resolved from its discovery document (/.well-known/openid-configuration); startup fails closed if they cannot be. eParaksts: set the EPARAKSTS_* variables instead — a named profile of the same connector carrying that provider's fixed endpoint paths, bespoke logout endpoint, scope and method vocabularies. Setting both refuses to start — one upstream per deployment, never a silent pick; a deployment that needs several providers side by side is a different composition of this service.

Several providers side by side: a Go program composing the service. This repository's server runs one provider from its settings and has no setting for more. A Go program that embeds the service can pass its own set to authbytecore.New instead, with authbytecore.WithUpstreams(entries ...upstream.Entry); the OIDC_UPSTREAM_* and EPARAKSTS_* settings are then neither required nor read, and routes.Init serves each entry's answers on its own path. Each entry names:

  • a Name, unique, which the sign-in records;
  • its Provider, built with upstream.New (or from upstream.EParakstsProfile), whose allowed methods are the entry's methods;
  • its ReturnPath on this service, unique, and not one of the service's own routes;
  • an optional DisplayName for the page's button and ACR, the provider's own words for each method;
  • HoldsStored, true on exactly one entry: the one whose people's sign-ins were stored before the deployment had several providers (see "State and data model").

The start is refused, naming what is wrong, when two entries claim one name, return path or method, when an entry claims webEid (the card's own door), allows no method, has no issuer or authority to keep its people under, or returns on one of the service's own routes. A sign-in goes to the entry of the method it asks for, and its answer is accepted only on that entry's return path, from the browser that started it, and, when the answer carries iss, naming that entry's issuer; anything else is refused before a code is exchanged with any provider (RFC 9700 §4.4.2, RFC 9207). A request naming ways of more than one entry in acr_values is shown the page with those ways.

The id_token path. When the provider publishes a key set — its discovery document names a jwks_uri, as every mainstream provider's does, or OIDC_UPSTREAM_JWKS_URL and OIDC_UPSTREAM_ISSUER are set for one configured by explicit endpoints — every login must carry an id_token that verifies: signed with one of the published keys (RS256 · PS256 · ES256; none and the HMAC family are refused), iss equal to the issuer, aud containing this client, exp and iat within TOKEN_CLOCK_SKEW_LEEWAY, the nonce this flow sent, and a subject equal to userinfo's. The id_token's claims win over userinfo's where both carry one, and its acrs — the authentication contexts the login satisfied, where a provider issues them — join the amr list, so LOA_POLICY maps a context to a level like any other token (a directory enforcing MFA under a context c1: c1=substantial). A missing or failing id_token refuses the login (401, audited with the reason). Without a key set — the eParaksts profile, a provider with fixed paths and none — userinfo alone is read, exactly as before this path existed. Two further claims serve an organisation whose people sign in through its own directory: OIDC_UPSTREAM_CLAIM_DIRECTORY_ID (default sub; oid for Microsoft Entra ID, whose sub is pairwise per application registration) names the durable identifier the person's credential is stored under, and OIDC_UPSTREAM_CLAIM_ACCOUNT_STATUS (acct for Entra) tells a member of that directory from a guest in it — a guest logs in like anyone else but is never admitted into a tenant by its attached directory (see the register boundary). The directory's email address is kept for such a person (OIDC_UPSTREAM_CLAIM_EMAIL, default email), so a caller that must reach them can read it back. Such a login carries no identity code: the identity store resolves the person by their credential and creates them without one; they stay a separate person from the same human's card login until linked by a deliberate act (a later increment).

A generic provider names nobody by identity code unless it is trusted to. A person is found by their code across every way they sign in, so a code that arrives from a provider that did not verify it would sign its sender in as whoever holds that code. The generic provider's code claim is read only with OIDC_UPSTREAM_TRUST_IDENTITY_CODES=true, for a provider that verified the people it signs in; off (the default), its people are found by its issuer and their handle alone, and the start is refused when OIDC_UPSTREAM_CLAIM_SERIAL is set, since the claim would never be read. The eParaksts profile is trusted always.

The claim named by OIDC_UPSTREAM_CLAIM_SERIAL must carry an identity code that states its own country — PNOLV-XXXXXXXXXXX, the prefixed form of ETSI EN 319 412-1. A provider that sends a bare national code with no country in it delivers logins this service refuses, and there is deliberately no setting to supply the missing country: one value would apply to every person who ever logs in through that provider, so a user base holding codes from more than one register would have some of them filed under the wrong one — a valid-looking key for the wrong person, with no error to notice it by. Map the claim at your provider instead, where the identity type can be stated alongside the country.

Env Default Meaning
OIDC_UPSTREAM_AUTHORITY_URL — Generic provider base (issuer); endpoints via discovery
OIDC_UPSTREAM_CLIENT_ID — Confidential client id at the provider
OIDC_UPSTREAM_CLIENT_SECRET (_FILE) — Client secret (secret store)
OIDC_UPSTREAM_SCOPES openid profile Scopes requested at authorization (space/comma separated)
OIDC_UPSTREAM_AUTHORIZE_URL / _TOKEN_URL / _USERINFO_URL / _END_SESSION_URL — (⇒ discovery) Absolute endpoint overrides for a provider with fixed or non-standard paths; setting the first three skips discovery
OIDC_UPSTREAM_TRUST_IDENTITY_CODES false The provider is trusted to name a person by the identity code it sends: set only for a provider that verified the people it signs in. Off, the code claim is not read
OIDC_UPSTREAM_CLAIM_SERIAL serial_number Userinfo claim carrying the person's identity code, read only from a trusted provider; setting it without OIDC_UPSTREAM_TRUST_IDENTITY_CODES=true refuses the start. Leave the claim absent at a provider that has none (a directory), never invent one
OIDC_UPSTREAM_ISSUER — (⇒ discovery issuer) The iss an id_token must carry; set together with _JWKS_URL for a provider configured by explicit endpoints
OIDC_UPSTREAM_JWKS_URL — (⇒ discovery jwks_uri) The provider's signing keys. Known ⇒ every login's id_token is verified and required; unknown ⇒ userinfo only
OIDC_UPSTREAM_CLAIM_DIRECTORY_ID sub Claim carrying the person's durable identifier at the provider — the handle their credential is stored under (oid for Entra)
OIDC_UPSTREAM_CLAIM_EMAIL email Claim carrying the person's email address (read from the id_token first where it is verified, then userinfo; trimmed). Kept only when it is address-shaped and the person holds no national identity code; an address carrying a control character, a line or paragraph separator or an invisible formatting character is dropped. A login is never refused over it
OIDC_UPSTREAM_CLAIM_ACCOUNT_STATUS — (⇒ nobody is a guest) Claim telling a member of the provider's directory from a guest in it (acct for Entra)
OIDC_UPSTREAM_ACCOUNT_STATUS_GUEST 1 The account-status value that marks a guest
OIDC_UPSTREAM_METHOD_POLICY — (⇒ profile default) acr/amr token → login-method vocabulary (substr=method,…, longest token wins)
OIDC_UPSTREAM_METHOD_DEFAULT upstream (generic) Login method when no vocabulary token matches
OIDC_UPSTREAM_METHODS_ALLOWED — (⇒ the default method; the eParaksts profile's eparakstsMobile,eidScan) Comma-separated set a callback may resolve to; anything else is refused (fail closed). It replaces the profile's set whole, so turning the eParaksts plugin card on is eparakstsMobile,eidScan,legacyEid
OIDC_UPSTREAM_METHODS_FEDERATED — (none) Comma-separated methods whose sign-out also goes through the provider's end-session endpoint, ending the session it keeps in the browser. Unset, signing out ends this service's session and leaves the provider's alone — a directory provider's session is the person's whole estate. The eParaksts profile lists its own methods regardless
OIDC_UPSTREAM_LOA_DEFAULT low Assurance level when no LOA_POLICY token matches — raise deliberately for an IdP that enforces MFA
OIDC_UPSTREAM_DISPLAY_NAME — (⇒ "Your organisation's sign-in", in the page's language) The generic provider's name on the sign-in page's button, e.g. Microsoft Entra
LOA_POLICY — (⇒ built-in: the eParaksts profile's level and method words; another provider's explicit level:* words only, then OIDC_UPSTREAM_LOA_DEFAULT) Override the acr/amr → assurance-level vocabulary (substr=loa,…; longest token wins, equal lengths in alphabetical order)
Env (eParaksts profile) Default Meaning
EPARAKSTS_AUTHORITY_URL — eParaksts authority base — setting it (without the generic variables above) selects this profile
EPARAKSTS_CLIENT_ID — (required with the profile) Confidential client id at the identity provider
EPARAKSTS_CLIENT_SECRET (_FILE) — Client secret (secret store)
EPARAKSTS_REDIRECT_PATH /callback Callback path (set to the registered value when it differs)
EPARAKSTS_SCOPES urn:lvrtc:fpeil:aa urn:safelayer:eidas:sign:identity:profile Login scope(s): electronic identification + the sign-identity profile (the latter enables login-time capability capture; drop it to turn capture off)
EPARAKSTS_ACR_MOBILE urn:eparaksts:authentication:flow:mobileid Method-forcing acr_values for an eParaksts Mobile sign-in
EPARAKSTS_ACR_EIDSCAN urn:eparaksts:authentication:flow:mobile-eid Method-forcing acr_values for an eID Scan sign-in
EPARAKSTS_ACR_LEGACY_EID urn:eparaksts:authentication:flow:sc_plugin Method-forcing acr_values for a sign-in with the eID card through eParaksts' plugin (legacyEid, where it is named among the methods)
EPARAKSTS_LOGOUT_IDP lvrtc-eipsign-idp IdP id for the logout endpoint (differs from the OAuth AS id)

Token signing + DPoP

Env Default Meaning
TOKEN_SIGNING_KEY (_FILE) — ES256 private-key PEM (secret store). Unset ⇒ refuses to start unless the dev opt-in below is set
TOKEN_SIGNING_ALG ES256 Signing algorithm (only ES256 accepted)
ALLOW_EPHEMERAL_SIGNING_KEY false Permit a generated in-memory key when none is configured — development only
SIGNING_KEY_RELOAD_INTERVAL 0 (disabled) Poll cadence for no-redeploy overlapping key rotation
USER_TOKEN_TTL 15m User access-token lifetime
SERVICE_TOKEN_TTL 5m Service / delegated token lifetime
APP_TOKEN_TTL 5m An AI app's token lifetime: the one token bound to no key; its assistant is asked about again at every refresh
DPOP_PROOF_MAX_AGE 60s DPoP proof freshness window
TOKEN_CLOCK_SKEW_LEEWAY 30s Clock-skew leeway for proof and nonce checks
DPOP_NONCE_ENABLED true Require a server DPoP-Nonce on issuance hops
DPOP_NONCE_TTL 5m Server-nonce lifetime

Backing stores + registry

Env Default Meaning
POSTGRES_DSN (_FILE) — (required) Identity-mapping DB; connects as the EXECUTE-only authbyte_public role. Pool size comes from the DSN itself — pool_max_conns (pgx reads it and strips it before Postgres sees it; its default is the host's CPU count): set it explicitly to the deployment's connection budget, e.g. ?sslmode=…&pool_max_conns=4&pool_min_conns=1.
CACHE_TYPE memory (refused) The store for login flows, application codes, sessions, refresh grants and the DPoP replay guard: redis, redis-cluster or redis-sentinel (Valkey or Redis). The service refuses to start on memory, in every environment: with two instances a login begun on one would be unknown to the other, and each would accept a replayed proof once
CACHE_CONNECTION — (required with redis*) The store's address, for example rediss://valkey.example:6380/0
CACHE_PASSWORD (_FILE) — The store's password. A CACHE_PASSWORD_FILE that is missing or empty stops the start, rather than connecting without the password
CACHE_KEY_PREFIX — A prefix for every key, when several services share one database
CACHE_CLIENT_CACHE false Client-side caching; off, because the store never reads through it and a server or user that refuses it would refuse the connection
SESSION_TTL 12h A session's whole lifetime from its sign-in. Nothing saved on the session later extends it; at its end every refresh is refused
SERVICE_CLIENT_REGISTRY (_FILE) — The client registry (below): every service client and every sign-in client (secret store)

TLS is selected by the URL scheme. rediss://… connects over TLS; redis://… does not. skip_verify=true only relaxes certificate verification on a rediss:// address; on a redis:// address the start is refused (skip_verify requires a TLS connection string scheme) rather than the connection being upgraded silently, so a TLS endpoint must always be addressed as rediss://.

Audit + Web eID + development

Env Default Meaning
ACCESS_AUDIT_URL — (empty ⇒ GDPR access logging OFF) access-audit base URL; NIS2 security telemetry always emits regardless
ACCESS_AUDIT_AUDIENCE / ACCESS_AUDIT_SCOPE svc:access-audit / access-audit:write Audience/scope for the outbound audit token
ACCESS_AUDIT_OUTBOX_DIR — (⇒ in-memory) Durable outbox dir for buffered access records
AUDIT_CLIENT_ID / AUDIT_CLIENT_SECRET (_FILE) svc:authbyte-core / — This service's own service-client identity for the outbound audit token; must be a registered client with a grant for the audit audience/scope
AUDIT_ISSUER_URL — (⇒ AUTH_ISSUER_URL) Issuer base for the outbound token mint (set to an in-cluster URL when the public issuer is unreachable)
WEBEID_ENGINE_URL — (empty ⇒ /webeid/* not registered) web-eid engine base; /auth/validate is called server-to-server with a service token
WEBEID_AUDIENCE / WEBEID_SCOPE svc:web-eid / webeid:validate Audience/scope for the engine call
ROLEBYTE_URL — (empty ⇒ standalone: each client's baseline scopes, no service accounts) Membership register base — setting it wires the register (see "Supported configurations"). When set, every token issue of a person whose client requires a membership (first issue and refresh) claims the person's pending invitations and mints their register-resolved group:level scopes and tenant instead of a baseline — no membership is refused (403 err:membership:notMember), several are the person's to choose from (the token response's tenants), and an unreachable register fails the issuance closed (502), never an empty-scope token; a client whose row says membership_required: false keeps its people on the baseline and never touches the register. Every tenant-named client_credentials issue is resolved here too. The address is the register's sign-in listener (http://rolebyte:8081 in the stacks), not its main port. Each call carries a token this service mints for it, naming the person and AUDIT_CLIENT_ID as the acting client, which must be the register's SIGN_IN_CLIENT; no registry grant for membership:claim or membership:resolve is needed, and none may exist
ROLEBYTE_AUDIENCE svc:rolebyte Audience for the membership calls
DEMO_DIR — Serve a static demo SPA under /demo — development

Registry client secrets resolve per client via the literal:/env:/file: schemes in the secret_ref, or the convention AUTHBYTE_CLIENT_SECRET_<CLIENT_ID> read from the environment or the secret store. literal:VALUE carries the secret inline in the registry document itself — for when the whole registry is delivered as a single secret (a secret-store entry) rather than resolved reference-by-reference; a failed literal: ref is redacted in errors so the value is never logged. Schemes may be mixed per client.

The client registry. One document names every client. service_clients are machines: each proves itself with its secret and is minted tokens for the audiences and scopes its grants allow (act_from names the user audiences whose tokens it may exchange). A grant's scope is a box (service/feature:act) or a level (group:level), and anything else stops the start; a grant's assistant_levels names those of its levels that may ride a token made through a person's assistant (empty by default, each one a level of the grant). sign_in_clients sign people in, and each row carries everything its product needs, so one deployment serves several products. The names follow the client metadata of [RFC 7591 §2] and the OpenID logout specifications where one exists:

sign_in_clients:
  - client_id: portal-spa
    enabled: true
    type: confidential            # holds a secret (secret_ref); or public: none, PKCE alone
    secret_ref: env:PORTAL_SECRET
    audience: portal-api          # the aud of this client's user tokens
    membership_required: false    # see "Supported configurations"
    baseline_scopes: [envelopes:read, documents:read]   # only where no membership is asked
    claims: [name, given_name, family_name, serial_number]   # the personal claims its tokens carry
    methods: [eparakstsMobile, eidScan, webEid]               # the sign-in methods it allows
    signing: true                 # told the signing flows; its sign-ins capture signing identities
    single_sign_on: false         # true: answered from the browser's shared session (never with signing: true)
    client_name: Example Portal   # kept for a hosted sign-in page; nothing shows it yet
    logo_uri: https://portal.example/logo.svg
    redirect_uris: [https://portal.example/callback]
    post_logout_redirect_uris: [https://portal.example/]
    backchannel_logout_uri: https://portal.example/backchannel-logout   # kept; nothing calls it yet

The document is read once at the start, and anything wrong in it stops the start: an unknown key (the earlier public_clients, allowed_redirect_uris and step_up_methods included), a row without an audience, a confidential row without a secret or a public row with one, a claim other than the four listed above, one id in both sections, a logo_uri that is not https, and an address that is not absolute or carries a fragment. A sign-out or back-channel address may use http only for a confidential client. A row none of whose methods this deployment offers is logged as a warning: one file can serve deployments whose providers differ, and such a row's sign-ins are refused where they are asked for.

An AI app (kind: ai_app) is a sign-in client a person connects to one MCP server as one of their own assistants: a public client, sent back only to a loopback or https address, whose audience is the MCP server's canonical address and whose scopes are the boxes it may ask for. It takes no claims, no signing, no single_sign_on and no baseline_scopes; anything else in such a row stops the start, and scopes on any other row does too:

  - client_id: claude-code
    kind: ai_app
    type: public
    client_name: Claude Code      # named on the assistant page
    audience: https://flowbyte.example/mcp
    enabled: true
    redirect_uris: [http://localhost:33418/callback]
    methods: [upstream]
    scopes: [projects/project:view, projects/task:view, projects/task:create, projects/task:edit,
             projects/task:changeStageOwn, projects/task/comment:add]

The MCP server's own service row lists that address under act_from, so it may exchange the app's token for the services it calls.


Audit

The audit package is a small Recorder façade over the platform emitters; the call sites in the login and token handlers are stable.

  • Security telemetry (NIS2), always on — login success/failure, logout, and user/service/delegated token issuance are emitted via go-sec-events as structured security_event lines the log pipeline ships to the SIEM. No broker dependency.
  • Personal-data access (GDPR), on when ACCESS_AUDIT_URL is set — the identity-record write the login performs is recorded as created (first login by any method) or updated (returning person / new method linked), and so is an administrator's registration of a person (POST /identity/persons; the administrator is the actor, the registered person the subject), each POSTed to access-audit with a DPoP-bound service token minted through this service's own /token. Delivery is routine / fail-open (local outbox + background drain), so a brief access-audit outage never fails a login.

Because this service is both the token issuer and a client of access-audit, AUDIT_CLIENT_ID must be a registered service client with a grant for the audit audience/scope, and minting the audit token is a self-call to its own /token.


Directory layout

authbyte/
├── app.go, config.go, auditposter.go   — App container, configuration, GDPR poster
├── testing.go                          — TestApp harness (lazy backing-store clients)
├── cmd/server/                         — CLI entrypoint (web, health subcommands)
├── signin/                            — the sign-in page's words, language choice, headers and files
├── routes/                             — HTTP handlers
│   ├── router.go       — route registration (optional surfaces fail-closed by absence)
│   ├── authorize.go    — /authorize · /callback · session establishment
│   ├── signin.go       — the sign-in page: its ways, the choice, the language, its files
│   ├── cookies.go      — the browser's binding, the page's token, the one-shot notice
│   ├── token.go        — /token: 4 grants (code, client-credentials, refresh, exchange)
│   ├── refresh.go      — refresh grants (the token's hash → session + client) · /revoke
│   ├── dpop.go         — inbound DPoP verify + server-nonce challenge + jti replay
│   ├── webeid.go       — the card's completion · its identity
│   ├── logout.go       — the browser's sign-out leg (through the provider for federated methods)
│   ├── identity.go     — /identity (permitted signing flows) · person registration
│   ├── persons.go      — service-only person reads: sign-in lines · identity code
│   ├── wellknown.go    — jwks.json · openid-configuration
│   ├── ready.go        — /readyz (dependency-aware)
│   ├── helpers.go      — PKCE verify, redirect building
│   ├── demo.go         — development-only demo SPA serving
│   └── request/, response/  — request validation + response DTOs
└── (packages)
    ├── keys/           — ES256 signer, JWKS, overlapping rotation
    ├── issuer/         — user / service / delegated token minting (cnf.jkt)
    ├── identity/       — acr/amr resolver + login-method ↔ signing-flow binding
    ├── registry/       — declarative client ↔ audience ↔ scope matrix
    ├── upstream/       — the generic upstream-OIDC connector (a provider is configuration; carries the eParaksts profile) and the set of providers a composing program may pass (set.go)
    ├── webeid/         — web-eid engine stateless-validate adapter
    ├── session/        — the cache-backed store: flows, app codes, sessions, refresh grants, DPoP replay
    ├── store/          — PostgreSQL person mapping (procedures only)
    └── audit/          — Recorder façade: sec-events (NIS2) + gdpr (GDPR)

Development

There is no Makefile; the standard Go toolchain is used directly (Go 1.26+).

go build ./cmd/server        # build the server binary
go test ./...                # unit tests
go vet ./...

# the store and tracing tests against a real Valkey or Redis (skipped without it)
VALKEY_TEST_URL=redis://localhost:6379/15 go test ./...

Crypto-critical paths are covered end to end: token issue → JWKS → verify (issuer), registry grant enforcement (registry), key rotation (keys), the acr/amr resolver and binding (identity), federated logout (routes), and full HTTP wiring (routes/router_test.go). TestApp builds a wired App with lazy backing-store clients, so tests that do not touch Valkey/PostgreSQL run without them; route tests use the in-memory store in session/sessiontest, which a contract test checks against the real one; DPoP and nonce primitives are tested in go-authbyte.

The container build is a multi-stage image (Dockerfile): a static CGO_ENABLED=0 binary on a minimal rootless scratch base (ghcr.io/wntrtech/scratch, non-root app user), with the web entrypoint and a health subcommand wired to the Docker HEALTHCHECK.


Security invariants

  • Fail closed on trust primitives — no signing key ⇒ refuses to start (unless the explicit dev opt-in); unknown/expired flow, app code, or session ⇒ 401; an identity-provider login that resolves to a method the deployment does not allow ⇒ 403; a dead cache/PostgreSQL ⇒ /readyz 503 and the pod leaves rotation.
  • Every issued token is sender-constrained — cnf.jkt binds the token to the caller's DPoP key; refresh and revocation require the same key the session was bound to and the client the refresh token was issued to. Issuance hops require a fresh DPoP proof with a server nonce, and each proof id is one-time-use across every instance (jti replay guard).
  • No session value in a browser address or the store — the internal session id never leaves the service; the refresh token is answered only to the client's server and kept only as a hash; sign-out ends the session server to server, and the browser's sign-out address carries no value naming it.
  • Single-use flow artifacts — login flows and app authorization codes are consumed atomically (GETDEL), so a replay under a race cannot reuse them; PKCE code_verifier is compared in constant time.
  • Least privilege to data — the database role is EXECUTE-only on stored procedures; the service never touches tables and never sees a schema column name in code. Service tokens are scoped to one audience by the registry grant matrix; a service subject can never be impersonated via token exchange.
  • Open-redirect protection — every redirect_uri (login, logout) is validated against the client's registered allowlist before any state is saved or any redirect issued.
  • No personal data in logs — the raw identity-provider userinfo payload is emitted only at debug level and must never be enabled in production; audit actors carry ids and assurance, not free-form PII, and platform log redaction is installed before any handler runs.

Known limitations

  • Assurance vocabulary is provisional — the built-in acr/amr → LoA mapping resolves the QSCD-backed eParaksts methods to high and reads a level:* URN when present; production's exact acr values must be confirmed and pinned via LOA_POLICY. The identity provider currently returns an identical amr for eParaksts Mobile and eID Scan, distinguished only in acr — the resolver scans both claims so this resolves correctly today and survives the upstream moving the signal.
  • Refresh material lives in the shared cache — a user session (and thus refresh) is only as durable as the Valkey or Redis behind CACHE_*; a flush logs everyone out. The store uses ordinary single-key commands (GET, SET with NX/XX/PX, GETDEL, DEL) and has been proven on a single, non-clustered Valkey and Redis; redis-cluster and redis-sentinel are accepted by the configuration but unproven here.
  • Trusted-proxy assumption — DPoP htu reconstruction honours X-Forwarded-Proto/X-Forwarded-Host and the client IP comes from X-Forwarded-For; the edge gateway MUST strip/overwrite these on inbound client traffic. They are never trusted from arbitrary origins.
  • The eParaksts plugin card is off by default — the card through eParaksts' own browser plugin (legacyEid) is permitted only where OIDC_UPSTREAM_METHODS_ALLOWED names it; card login on this service's own page requires the Web eID surface and the WEBEID_ENGINE_URL engine to be configured. Which acr/amr eParaksts answers for the plugin is read from its demo's flow name and is to be confirmed on a real card.
  • GDPR access logging is best-effort — when access-audit is unreachable, records buffer to the outbox (durable only when ACCESS_AUDIT_OUTBOX_DIR is set) and drain in the background; a login is never blocked on audit pressure, so a lost outbox on an in-memory deployment means lost access records rather than a failed login.

About

The fleet's OAuth 2.0 authorization server — brokers eID logins to one stable person subject and mints DPoP-bound access tokens

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages