Reaktor Auth

Desktop inspection status · 8 September 2026: the Auth pane now has bounded metadata directories and session Postgres connection setup. Framework auth capability does not imply a delivered desktop admin workflow: role administration, credential minting and revocation are still open. See the living UI/UX audit for implemented flows and verification.

This is the current source-of-truth document for reaktor-auth as deployed in the BestBuds/Reaktor stack on June 5, 2026.

The important shift is complete: Reaktor auth is not a login helper. It is a graph-native principal, token, session, and authorization engine. Provider login, Supabase email credentials, anonymous entry, refresh rotation, PAT exchange, service credentials, delegated tokens, Spring Security, Cloudflare Workers, graph ports, and target-owned E2E tests all converge on the same primitive: AuthContext.

01. Design Contract

The API priority is:

expressivity and composability >> simplicity >>> one-shot APIs / bloated APIs

The 80% path must stay small:

  1. Client calls AuthAdapter.login(...) or a service calls /auth/token.
  2. The runtime resolves a principal and mints a short-lived Reaktor token.
  3. Servers and Workers verify the token locally.
  4. Handlers, graph ports, and actors authorize against AuthContext plus AuthRequirement.

The remaining 20% path must still be possible:

  • Graph consumers can mount AuthRuntimeGraph and wire directly to capability ports such as AuthLogin, AuthPat, AuthTokenGrants, and AuthSessions.
  • Infrastructure consumers can drop down to lower-level ports such as AuthPrincipalDirectory, AuthPersonalTokens, AuthSessionLifecycle, and AuthServiceAccounts.
  • Frameworks stay adapters. Spring and Cloudflare do not own the auth model; they translate transport credentials into graph/kernel primitives.

02. Current Status

AreaStatusNotes
Graph runtimeImplementedAuthRuntimeGraph is the JVM composition root and a real child-capable Graph.
Kernel authorizationImplementedAuthContext, AuthRequirement, and LocalAuthorizer are pure commonMain primitives.
Identity modelImplementedidentity, provider_account, principal, membership, tenant/context, RBAC, sessions, PATs, service accounts, signing keys.
User loginImplemented server sideExternal ID token -> principal/membership -> session -> rotating refresh + ES256 access token.
Anonymous loginImplemented core, policy hardening openAuthLogin.anonymous, AnonymousAuthRequest, /auth/anonymous, resolveAnonymousLogin, AuthMethod.ANONYMOUS, credential_type=anonymous, guest role/scope config, session creation, refresh, audit, and Spring public-route wiring exist. App bootstrap policy, visible first-launch UX, per-flow demo limits, Worker route policy, and anonymous-to-provider upgrade are still open.
Refresh rotationImplementedOne-time-use rkr_ refresh tokens, SHA-256 at rest, family reuse detection.
Access tokensImplementedES256, typ=at+jwt, JWKS, issuer/audience/expiry validation.
PATsImplementedrkt_ high-entropy token with offline checksum, hash-at-rest, expiry, scopes, app/audience binding. Legacy rak_ verifies for compatibility.
Service accountsImplementedclient_credentials, private key JWT or secret auth, audience binding, scope down-scoping.
DelegationImplemented token shapeRFC 8693 token exchange with user subject and service actor in act. Persisted may_act policy is still future work.
Auth auditImplementedauth_audit_event records token mint, exchange, refresh, revoke, and auth failure events without raw tokens or secrets.
Spring adapterImplementedSpringDependencyAdapter creates the runtime; WebFlux filter verifies tokens and exposes AuthContext.
Worker authImplemented in consumersBestBuds Workers verify Reaktor JWTs through JWKS. Worker-to-Worker and Worker-to-server calls use service-account JWTs, not static fallback tokens.
Target-owned E2EImplementedKarate lives under tests/<target>/karate; k6 under tests/<target>/k6.
Supabase email/password, magic link, and OTPDesigned here, not implementedAuthProviderKind.SUPABASE and provider-account storage exist, but there is no Supabase verifier, email facade, password reset facade, or link/upgrade endpoint yet. Supabase proves the email credential; Reaktor still owns principal, membership, sessions, tokens, authorization, audit, and Worker/server verification.
Browser BFF / PKCE / DPoPNot implementedNative/mobile login works through provider ID tokens today. Supabase flows make PKCE and deep-link callback handling concrete; BFF cookies and DPoP remain later hardening.
ReBAC object tuplesNot implementedKernel has resource-aware requirements; persistent object relationship tuples remain future work.

03. System Shape

External credential
  Google / Apple / Supabase / Reaktor
        |
        v
AuthRuntimeGraph
  AuthJwtNode verifies external identity
  AuthRepositoryNode resolves app, identity, principal, membership, roles, permissions
  AuthSessionLifecycleNode creates or rotates sessions
  AuthJwtNode mints ES256 access tokens
        |
        v
Reaktor access token
  iss=https://api.reaktor.build/auth
  aud=<app or service audience>
  sub=<principal id>
  principal_type=user|service|agent|actor
        |
        v
Spring / Worker / graph / actor boundary
  verify locally -> AuthContext -> LocalAuthorizer/AuthRequirement
        |
        v
Domain handler

The same token can be verified by:

  • ReaktorSecurityWebFilter inside a JVM/Spring server.
  • WorkerJwtVerifier inside Cloudflare Workers.
  • JwtVerifier.verifyReaktorToken(...) in JVM services.
  • Graph-aware consumers that connect to runtime capability ports.

No verifier needs the signing secret. Public keys are exposed at /.well-known/jwks.json.

04. Core Model

The phase-2 schema intentionally separates "who the person is" from "what is acting" and "where that actor has authority".

ConceptTable / typeMeaning
AppappProduct/application namespace. Roles, permissions, contexts, tenants, sessions, and credentials are app-scoped.
IdentityidentityGlobal human identity. A person can have many provider accounts and app memberships.
Provider accountprovider_accountExternal login method attached to an identity: Google, Apple, Supabase, or Reaktor. Unique by provider, issuer, subject.
PrincipalprincipalAuthorizable caller: USER, SERVICE, AGENT, or ACTOR. The anonymous MVP should reuse USER with identity_id=null and credential_type=anonymous until a separate PrincipalKind.ANONYMOUS is worth the downstream compatibility cost.
MembershipmembershipPrincipal membership in an app, tenant, and optional context.
TenanttenantOrganization/unit inside an app.
ContextcontextApp-local authorization scope for a resource, workflow, tenant slice, or graph context.
RBACrole, permission, role_permissions, principal_roleApp-scoped role/permission grants, optionally context-scoped.
SessionsessionPrincipal login session. Refresh-token families are anchored here.
Refresh tokenrefresh_tokenOne-time-use rotating token family. Reuse revokes the family.
PATpersonal_access_tokenLong-lived secret handle exchanged for short-lived JWTs. Raw token is shown once; only hash is stored.
Service accountservice_accountMachine credential for client_credentials and delegated token exchange.
Signing keysigning_keyPublic signing-key metadata and private-key reference. Runtime private keys come from config/secrets.
Auth audit eventauth_audit_eventAppend-only security trail for token mint, exchange, refresh, revoke, and failure events. Stores ids, scopes, request metadata, and reason; never raw bearer tokens, refresh tokens, PATs, or service secrets.

The canonical schema is reaktor-auth/src/jvmMain/kotlin/dev/shibasis/reaktor/auth/heimdall.sql. It is not a legacy migration script. The intended rebuild path is:

psql "$SUPABASE_DB_URL" -f reaktor-auth/src/jvmMain/kotlin/dev/shibasis/reaktor/auth/heimdall.sql

Use a full external dump of Supabase heimdall and public data before rebuilding. Restore only data that belongs in the phase-2 schema. The old users / user_role flow is not a supported runtime path.

05. AuthContext and AuthRequirement

AuthContext answers one question: who is calling, for what app/tenant/context, with what authority?

data class AuthContext(
    val principal: PrincipalRef,
    val identityId: String?,
    val appId: String,
    val tenantId: String?,
    val contextId: String?,
    val sessionId: String?,
    val tokenId: String?,
    val credentialId: String?,
    val issuer: String,
    val audience: String,
    val scopes: Set<PermissionRef>,
    val roles: Set<RoleRef>,
    val permissions: Set<PermissionRef>,
    val method: AuthMethod,
    val actor: PrincipalRef?,
    val delegation: Delegation?,
)

AuthRequirement is the declarative gate:

val canWrite = AuthRequirement(
    permissions = setOf(PermissionRef(name = "messaging:write")),
    principalKinds = setOf(PrincipalKind.USER, PrincipalKind.SERVICE),
).audience(bestbudsAppId)

LocalAuthorizer evaluates:

  1. Missing auth context -> 401.
  2. Audience/app/tenant mismatch -> 401 or 403.
  3. Wrong principal kind -> 403.
  4. Missing scope, permission, or role -> 403.
  5. Delegated call without allowDelegation -> 403.
  6. Otherwise allow.

This is pure commonMain logic, so servers, Workers, graph ports, and future actor mailboxes can share the same authorization law.

06. Token Families

CredentialFormatStorageUse
External provider tokenGoogle/Apple/Reaktor provider tokenNot trusted as Reaktor API tokenLogin proof only.
Reaktor access tokenES256 JWT, typ=at+jwt, kid, JWKSClient memory / short-lived bearerAPI, Worker, graph, and service calls.
Refresh tokenrkr_ opaque tokenSHA-256 hash in refresh_tokenUser session rotation. One-time-use.
PATrkt_ opaque token with checksumSHA-256 hash in personal_access_tokenDeveloper/tool/CI bootstrap, exchanged for access JWT.
Service accountclient_id plus private key JWT or secretservice_account rowclient_credentials and token exchange actor auth.
Delegated tokenES256 JWT with act claimShort-lived bearerService acts for a user while retaining actor identity.

Access token claims include:

{
  "iss": "https://api.reaktor.build/auth",
  "aud": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "sub": "<principal-id>",
  "jti": "<token-id>",
  "sid": "<session-id>",
  "identity_id": "<identity-id>",
  "principal_type": "user",
  "credential_type": "access_token",
  "scp": ["profile:read", "messaging:read"],
  "roles": ["member"],
  "permissions": ["messaging:read"],
  "act": { "sub": "<service-principal-id>" },
  "iat": 1760000000,
  "exp": 1760000900
}

The verifier pins issuer, audience where required, expiry, and algorithm. It accepts at+jwt, JWT, or absent JOSE typ; this avoids the Nimbus default processor rejecting every RFC 9068-style access token.

07. Runtime Graph Decomposition

AuthRuntimeGraph is the JVM composition root and is itself a Graph:

val runtime = AuthRuntimeGraph.create(
    adapter = exposedAdapter,
    config = AuthRuntimeConfig(
        userAuthenticationProviders = providers,
        ecJwkJson = ecJwkJson,
        ecJwkPrevJson = ecJwkPrevJson,
    ),
)

or mounted under another graph:

val auth = graph.installAuthRuntime(exposedAdapter, config)

The runtime registers only two external dependencies:

  • ExposedAdapter, qualified as reaktorAuth.exposedAdapter.
  • AuthRuntimeConfig, qualified as reaktorAuth.config.

Everything else is graph composition.

NodeProvidesDepends on
AuthAuditNodeAuthAuditSinkExposedAdapter (writes the append-only auth_audit_event trail)
AuthRepositoryNodeAuthAppCatalog, AuthPrincipalDirectoryExposedAdapter
AuthJwtNodeJwtMinter, JwtVerifier, AuthExternalIdentityVerifier, signing keysAuthRuntimeConfig
AuthPersonalTokenNodeAuthPersonalTokensJwtMinter, database-backed token service
AuthSessionLifecycleNodeAuthSessionLifecycledatabase-backed session service
AuthServiceAccountNodeAuthServiceAccountsJwtMinter, database-backed service accounts
LoginInteractorNodeAuthLoginapp catalog, principal directory, external verifier, session lifecycle, minter
AuthPatNodeAuthPatpersonal token port
AuthTokenGrantNodeAuthTokenGrantsPAT, service accounts, verifier, minter
AuthSessionNodeAuthSessionssession lifecycle, principal directory, verifier, minter
AuthHttpServiceNodeAuthHttpServicelogin, PAT, token grants, sessions
AuthAppServiceNodeAuthAppServiceapp catalog
ServiceNode projectionsroute handlersAuthService, AppService

This decomposition is the key Reaktor principle in practice. The common API is compact, but graph consumers can compose from lower-level atoms without expanding the top-level API.

08. HTTP Surface

The graph capability ports are projected as service handlers. In BestBuds, these are mounted under /auth.

RouteCapabilityBehavior
POST /auth/sign-inAuthLoginVerifies external provider token, resolves principal/membership, creates session, returns access + refresh.
POST /auth/anonymousAuthLogin.anonymousCreates or resumes a server-controlled guest principal for an app/environment, creates a refreshable session, and returns the normal access + refresh token shape with credential_type=anonymous.
POST /auth/sign-in with provider=SUPABASEDesigned AuthLogin extensionExchanges a verified Supabase access token into a Reaktor session after the Supabase verifier is added. This is the smallest client-first MVP and should not make Supabase JWTs valid Reaktor API bearers.
POST /auth/email/startDesigned email facadeStarts app-policy-checked Supabase email/passwordless flow: magic link or OTP, redirect allowlist, shouldCreateUser, environment, tenant/context hints, rate limits, and audit.
POST /auth/email/verifyDesigned email facadeVerifies Supabase email OTP or token hash, resolves/links the provider account, and returns Reaktor tokens.
POST /auth/email/password/sign-inDesigned password facadeAuthenticates via Supabase email/password behind Reaktor app policy, then returns Reaktor tokens.
POST /auth/email/password/reset/startDesigned password facadeStarts Supabase password reset with Reaktor redirect policy and audit.
POST /auth/email/password/updateDesigned password facadeCompletes password update from an authenticated reset/update flow.
POST /auth/anonymous/linkDesigned link facadeVerifies the anonymous bearer and a verified Supabase/Google/Apple/Reaktor credential, then attaches identity to the existing guest principal when app policy allows upgrade.
POST /auth/tokenAuthTokenGrantsSupports pat, personal_access_token, client_credentials, and RFC 8693 token exchange.
POST /auth/pat/mintAuthPatMints a PAT when authorized by bootstrap token or existing PAT with auth:pat:mint.
POST /auth/pat/verifyAuthPatVerifies raw PAT and returns token metadata.
POST /auth/session/refreshAuthSessionsRotates refresh token; replayed old token revokes the family.
POST /auth/session/logoutAuthSessionsRevokes the refresh-token family for the presented refresh token.
POST /auth/session/meAuthSessionsIntrospects verified bearer claims into principal, scopes, roles, permissions, and actor.
POST /auth/session/logout-allAuthSessionsRevokes all sessions for the bearer principal.
GET /.well-known/jwks.jsonJwksControllerPublishes public ES256 keys.
GET /app, GET /app/{id}AuthAppServiceApp catalog lookup.

Some credential-exchange routes are public at Spring Security because their handler verifies the credential itself. For example, /auth/token cannot require an access token before it mints one, but AuthTokenGrantNode still authenticates the PAT or service account.

09. Spring Boundary

Spring is intentionally a thin adapter.

AuthSpringConfiguration:

  • Reads ExposedAdapter.
  • Reads configured UserAuthenticationProvider beans.
  • Reads reaktor.jwt.ec-jwk and reaktor.jwt.ec-jwk-prev.
  • Creates AuthRuntimeGraph with SpringDependencyAdapter.
  • Exposes AuthServer, AppServer, ReaktorSecurityWebFilter, and JwksController.

DefaultSecurityConfig:

  • Installs ReaktorSecurityWebFilter at AUTHENTICATION.
  • Permits infrastructure and credential-exchange routes.
  • Requires authentication for everything else.
  • Uses NoOpServerSecurityContextRepository.

ReaktorSecurityWebFilter:

  • Extracts Authorization: Bearer <token>.
  • Verifies Reaktor ES256 signature locally.
  • Converts claims to AuthContext.
  • Places the context in exchange.attributes["reaktor.auth.context"].
  • Creates a Spring Authentication with SCOPE_, ROLE_, and PERMISSION_ authorities for framework-level integration.

Fine-grained authorization still belongs in AuthRequirement plus LocalAuthorizer, not in scattered route-local string checks.

10. Cloudflare Worker Boundary

BestBuds Workers now treat Reaktor JWTs as the client identity boundary:

  • User-facing routes require Authorization: Bearer <reaktor-access-token>.
  • Workers verify against https://api.reaktor.build/.well-known/jwks.json.
  • Audience is pinned to the BestBuds app id 38d1cee3-004d-441f-8f66-ad5060859af1.
  • The legacy X-BestBuds-Dev-User path is gated by ALLOW_USER_HEADER_AUTH=true and is not the production auth path.

Service-to-Worker calls support two Reaktor-issued shapes:

  1. Reaktor service JWT with principal_type=service, audience-bound and scoped with worker:server.
  2. Delegated Reaktor JWT with user subject and service actor.

Static Worker fallback tokens are no longer part of the production path. A Worker that needs to call another Worker or reaktorServer mints a short-lived service token through /auth/token with its service-account credentials.

11. User Flow

1  Client receives provider credential
2  Client calls POST /auth/sign-in with appId, provider, idToken, hints
3  AuthJwtNode verifies external identity
4  AuthRepositoryNode resolves app, identity, provider account, principal, membership
5  AuthSessionLifecycleNode creates session + first rotating refresh
6  AuthJwtNode mints ES256 access token
7  Client stores refresh securely and sends access token as bearer
8  Server/Worker verifies locally and builds AuthContext

Implemented:

  • Google/Apple/Reaktor-style external verification through configured providers.
  • Anonymous guest-session creation through /auth/anonymous.
  • Identity/principal/membership resolution.
  • Session creation.
  • Rotating refresh.
  • /auth/session/me, logout, logout-all.

Still open:

  • Supabase email/password, magic-link, and OTP provider adapter.
  • Anonymous app bootstrap policy, route policy, and anonymous-to-provider link/upgrade.
  • Browser Authorization Code + PKCE — for Reaktor's own browser login. The separate question of obtaining an OAuth grant at the provider is §31.
  • BFF __Host- HttpOnly cookie model.
  • DPoP sender-constrained browser/native tokens.

12. Supabase Email, Magic Link, and OTP Auth

The Reaktor rule is simple: Supabase can prove an email credential, but it does not become the Reaktor authorization system.

Supabase is excellent at hosted email/password auth, magic links, OTP email codes, email templates, SMTP delivery, and provider-side rate limits. Reaktor is the system that turns a verified credential into an app-scoped principal, membership, session, access token, refresh-token family, graph auth context, Worker boundary, and audit trail. A Supabase access token is therefore login proof. It is not a bearer token for Reaktor Workers, graph ports, or services.

Ownership Boundary

LayerOwns
Supabase AuthEmail/password verification, email confirmation, password reset, magic-link and OTP delivery, provider-side session proof, email templates, SMTP integration, provider-side throttles.
Reaktor authApp/provider configuration, redirect allowlist, tenant/context hints, identity/provider-account mapping, principal and membership creation, roles/scopes/permissions, Reaktor sessions, Reaktor access and refresh tokens, Worker/server verification, graph authorization, audit, account linking, anonymous upgrade.

That boundary keeps provider choice replaceable. A future app can use Supabase, Google, Apple, Reaktor-native email, enterprise SSO, or passkeys without changing the downstream authorization model.

Current Code Fit

The model already has the right slot:

  • AuthProviderKind.SUPABASE exists in the common auth kernel.
  • provider_account is unique by (provider, issuer, subject) and can store Supabase auth.users.id as the subject, the project auth URL as issuer, and the verified email metadata.
  • auth_provider_client can store per-app/per-platform provider config, redirect URLs, issuer, enabled flag, and secret references.
  • LoginRequest(provider=SUPABASE, idToken=<supabase-access-token>) can be the compact exchange DTO once a Supabase verifier exists.

The missing implementation pieces are explicit:

  • UserAuthenticationProvider.Supabase(projectUrl, jwksUrl, audiences, issuer).
  • Supabase JWT verification in JwtVerifier with issuer, audience, kid, signature, expiry, and email-verified handling.
  • Optional Reaktor email facade endpoints for policy-controlled start, verify, password sign-in, password reset, and callback handling.
  • Link/upgrade support that can attach a verified Supabase identity to an existing anonymous principal.

Supported Flow Families

There should be two integration styles. The first ships quickly; the second is the production browser shape.

Flow familyShapeWhen to use
Client-first exchangeNative, desktop, or SPA client uses Supabase SDK for signUp, signInWithPassword, signInWithOtp, verifyOtp, or PKCE exchange, then sends the resulting Supabase access token to POST /auth/sign-in with provider=SUPABASE. Reaktor verifies the token and returns Reaktor tokens.Fast MVP, native clients, prototypes, and apps that can keep provider SDK state local while still using Reaktor tokens for every backend call.
Reaktor email facadeClient calls Reaktor endpoints such as /auth/email/start, /auth/email/verify, /auth/email/password/sign-in, /auth/email/password/reset/start, /auth/email/password/update, and /auth/supabase/confirm. Reaktor validates app policy and redirect allowlists, calls Supabase, resolves identities, and returns Reaktor sessions.Production browser auth, BFF flows, store-review flows, multi-app policy, unified audit, and any product that must avoid scattering Supabase configuration across clients.

The client-first exchange should be intentionally narrow:

POST /auth/sign-in
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "provider": "SUPABASE",
  "idToken": "<supabase-access-token>",
  "environment": "PROD",
  "tenantHint": "optional-app-owned-hint",
  "contextHint": "optional-app-owned-hint"
}

The response is the normal LoginResponse.Success with Reaktor access and refresh tokens. The client must call Reaktor APIs with the Reaktor access token, not with the Supabase access token. If the client keeps a Supabase session for provider UX, that session is provider proof only.

Email and Password

Email/password should use Supabase for password storage and verification, then exchange into Reaktor:

client -> Supabase signInWithPassword(email, password)
       -> Supabase access token
       -> POST /auth/sign-in provider=SUPABASE
       -> Reaktor principal/session/token set

For the facade shape:

POST /auth/email/password/sign-in
  -> validate app + platform + redirect policy
  -> call Supabase password sign-in
  -> verify returned Supabase access token
  -> resolve provider_account + principal + membership
  -> mint Reaktor access + refresh

Password reset should stay policy-aware:

POST /auth/email/password/reset/start
  -> validate email, app, platform, redirect URL, rate limit
  -> call Supabase resetPasswordForEmail
  -> send user to Reaktor/Supabase callback

POST /auth/email/password/update
  -> require authenticated reset/update proof
  -> call Supabase updateUser(password)
  -> keep or rotate the Reaktor session according to app policy

Hosted Supabase projects require email verification by default. Reaktor should honor that: a route that requires a verified account must require emailVerified=true from the verified Supabase claims or callback result.

Supabase magic links are passwordless login proof. Reaktor should use them in one of two ways:

  1. Client-first: client calls Supabase signInWithOtp(email, ...); the email link returns to the app; the app obtains a Supabase session; the app exchanges the Supabase access token through /auth/sign-in.
  2. Facade: client calls /auth/email/start; Reaktor applies app policy and asks Supabase to send a magic link whose callback returns to a Reaktor-controlled route such as /auth/supabase/confirm.

Important policy:

  • shouldCreateUser=false for sign-in flows.
  • shouldCreateUser=true only for explicit signup, trial, invite, or anonymous-upgrade flows.
  • Magic-link destination must be an allowlisted redirect URL for the app and platform.
  • The callback must consume the provider proof once and mint a Reaktor session.
  • Never log the full magic link, token_hash, provider access token, provider refresh token, or Reaktor tokens.

For PKCE-backed magic links, the email template should carry a token hash to a callback route:

https://api.reaktor.build/auth/supabase/confirm?token_hash=<hash>&type=email&state=<opaque-state>

The callback exchanges the hash with Supabase, verifies the resulting session, binds it to the stored Reaktor flow state, and then either sets the future BFF session cookie or redirects the native/web client with an opaque app state that can be exchanged for Reaktor tokens.

Email OTP

Email OTP uses the same Supabase passwordless machinery as magic links, but the email template exposes a short numeric code instead of a link. Reaktor should model it as the same start/verify family:

POST /auth/email/start
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "method": "email_otp",
  "email": "person@example.com",
  "shouldCreateUser": false,
  "environment": "PROD"
}
POST /auth/email/verify
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "method": "email_otp",
  "email": "person@example.com",
  "otp": "123456",
  "environment": "PROD"
}

The verify endpoint returns the same Reaktor token set as any other login. Provider OTPs must be short-lived, rate-limited, single-purpose, and redacted from every log/audit field. Audit should store method=email_otp, provider=SUPABASE, app id, outcome, reason, request metadata, and principal ids after resolution.

Every redirect is an auth boundary. Reaktor should store app/platform redirect allowlists in auth_provider_client.redirect_uris and reject any callback or start request whose redirect is not allowed.

Recommended rules:

  • Exact HTTPS redirects for production web.
  • Universal links / app links for production iOS and Android.
  • Custom schemes only when the platform requires them or for development.
  • Wildcards only for localhost, preview deployments, and controlled development environments.
  • The callback should carry opaque state, not user authority. Reaktor resolves state to server-side flow metadata.

The future browser BFF model should use the Supabase PKCE callback as provider proof and then set Reaktor-owned __Host- HttpOnly secure cookies for browser sessions. Supabase sessions are not the browser API bearer; they are input to the Reaktor session minting step.

Anonymous Upgrade and Account Linking

Anonymous upgrade is a link operation, not a second unrelated user creation:

anonymous Reaktor bearer + verified Supabase credential
  -> verify anonymous Reaktor token
  -> verify Supabase access token, OTP result, or token_hash callback
  -> require verified email when app policy requires verified accounts
  -> create or resolve identity + provider_account(provider=SUPABASE)
  -> attach identity_id to the existing guest principal
  -> preserve principal id and intentional guest state
  -> mint a non-anonymous Reaktor session

Never link by unverified email alone. The stable key is provider=SUPABASE + issuer + subject; email is metadata and a recovery hint. Automatic linking by verified email can be a product policy, but it must be explicit and auditable because it changes identity ownership.

Data and Configuration

Minimum persisted config:

auth_provider_client
  app_id
  provider = SUPABASE
  platform = WEB | IOS | ANDROID | DESKTOP | SERVER
  issuer = https://<project-ref>.supabase.co/auth/v1
  client_id = <supabase-project-ref-or-publishable-key-id>
  client_secret_ref = <secret-manager-ref-for-server-only-supabase-key>
  redirect_uris = [...]
  enabled = true

Minimum provider account mapping:

provider_account
  provider = SUPABASE
  issuer = https://<project-ref>.supabase.co/auth/v1
  subject = <supabase-auth-user-id>
  email = <verified-or-claimed-email>
  email_verified = <claim/result>
  provider_tenant = <project-ref-or-null>

The facade flow also needs short-lived server-side flow state. This can start as an implementation-private table or store and later become a graph-visible node:

auth_external_flow
  id
  state_hash
  app_id
  provider
  method
  email_hash
  redirect_uri
  platform
  flow_id
  tenant_hint
  context_hint
  code_verifier_ref
  anonymous_principal_id
  created_at
  expires_at
  consumed_at

DTO Sketch

Keep the high-level API compact. Supabase should add a small email facade, not a large set of special-purpose auth endpoints:

data class SupabaseExchangeRequest(
    val appId: String,
    val accessToken: String,
    val tenantHint: String? = null,
    val contextHint: String? = null,
)

data class EmailAuthStartRequest(
    val appId: String,
    val email: String,
    val method: EmailAuthMethod,
    val shouldCreateUser: Boolean = false,
    val redirectUri: String? = null,
    val flowId: String? = null,
)

data class EmailAuthVerifyRequest(
    val appId: String,
    val email: String,
    val method: EmailAuthMethod,
    val otp: String? = null,
    val tokenHash: String? = null,
    val state: String? = null,
)

data class PasswordSignInRequest(
    val appId: String,
    val email: String,
    val password: String,
)

data class PasswordResetStartRequest(
    val appId: String,
    val email: String,
    val redirectUri: String,
)

data class PasswordUpdateRequest(
    val appId: String,
    val newPassword: String,
    val state: String? = null,
)

LoginRequest(provider=SUPABASE, idToken=...) should remain valid for the client-first exchange. The email facade exists to centralize policy, redirects, rate limits, and audit around Supabase-hosted email flows.

Security Rules

  • Verify Supabase issuer, audience, signature, kid, expiry, and required email verification before resolving identity.
  • Do not trust Supabase role, RLS, or application metadata as Reaktor permissions. Reaktor roles, scopes, and permissions come from Reaktor membership and policy.
  • Do not accept raw Supabase access tokens as API bearer tokens at Workers, Spring routes, graph ports, or service handlers.
  • Keep Supabase service-role keys server-side only, referenced through Secret Manager or Cloudflare secrets. They must never reach browser, mobile, desktop, or generated JS bundles.
  • Use custom SMTP for production email. Provider default email is acceptable only for development and smoke tests.
  • Apply Reaktor-side rate limits to email start, OTP verify, password sign-in, reset start, and callback consumption, even when Supabase also rate-limits.
  • Store only hashed email or redacted email in temporary flow state unless a product reason requires cleartext.
  • Redact magic links, token_hash, OTP values, Supabase access/refresh tokens, Reaktor access tokens, and Reaktor refresh tokens from logs and audit.
  • Treat redirect allowlists as production security config. Preview wildcards should be environment-scoped and removable.

Acceptance Tests

Supabase support is not done until these pass:

  • UserAuthenticationProvider.Supabase accepts a real Supabase JWT and rejects wrong issuer, wrong audience, expired token, tampered signature, missing sub, and unexpected algorithm.
  • POST /auth/sign-in provider=SUPABASE returns Reaktor access + refresh and persists provider_account(provider=SUPABASE, issuer, subject).
  • A Worker accepts the returned Reaktor JWT and rejects the raw Supabase JWT.
  • Email/password sign-in returns Reaktor tokens and never exposes a Supabase refresh token as a Reaktor refresh token.
  • Magic-link start respects app redirect allowlist and shouldCreateUser.
  • PKCE/token-hash callback is single-use and produces exactly one Reaktor session.
  • Email OTP verify succeeds once, rate-limits retries, and redacts the OTP from logs and audit.
  • Password reset start rejects non-allowlisted redirects.
  • Anonymous upgrade preserves the principal id, attaches the Supabase identity, and mints a non-anonymous session.
  • Unverified email cannot link or upgrade when policy requires verified email.
  • Supabase service-role secrets are absent from client bundles and generated Kotlin/JS artifacts.

Supabase Reference Points

This design relies on current Supabase behavior:

  • Email/password auth supports email identities and hosted projects require email verification by default.
  • Passwordless email supports both magic links and OTP codes.
  • Magic links are one-time-use links; Supabase's default request interval is 60 seconds and the default expiry is 1 hour.
  • OTP uses the same passwordless machinery as magic links, with a template that exposes a short code; the default request interval is 60 seconds and expiry is 1 hour.
  • Supabase sessions contain an access JWT and a refresh token. Reaktor treats that session as provider proof and issues its own session/token family.
  • Redirect URLs are configured allowlists. Native/mobile flows need deep links or universal/app links.
  • Identity linking must avoid unverified-email automatic linking because that is a pre-account-takeover risk.

13. Anonymous Auth Flow

Anonymous auth should be a first-class Reaktor grant, not fake Google login, fake Supabase login, or a shared demo credential.

The product purpose is clear:

  • Let people try apps built with Reaktor before creating an account.
  • Let Apple App Store and Google Play review staff open the app, inspect the experience, and approve releases without private setup.
  • Support apps whose useful surface is public content, where account creation is not required for reading, browsing, searching, or previewing.
  • Let a product collect local guest state during onboarding and preserve it if the user later upgrades to a real account.

This is also a product contract, not only a backend grant. Anonymous guest mode must be visibly reachable from first launch. A reviewer or new user should not need hidden gestures, support instructions, seeded private credentials, or a separate review build to enter the app. The first-run auth screen can still lead with the normal sign-in methods, but it must also show a clear guest/demo entry when the app has anonymous auth enabled.

The guest session must expose enough of the real app to test the core feature set. It should not land in an empty shell, a fake marketing tour, or a dead-end placeholder. For public-content apps this usually means real read/search/browse surfaces. For workflow, messaging, marketplace, creator, or collaboration apps, it can mean a seeded demo workspace, ephemeral writes, sample conversations, or other app-owned demo state that exercises the primary flows without exposing private user data or irreversible actions.

Apps should be able to customize their anonymous auth and demo policy. Reaktor should provide the primitive; each app decides which guest flows exist and what authority they carry:

Policy areaExamples
AvailabilityDisabled, enabled in review builds only, enabled in production, enabled by country/store channel, enabled by app environment.
Entry points"Continue as guest", "Try demo", "Browse public content", store-review deep link, or app-specific onboarding variant.
Guest authorityRead-only public content, demo workspace read/write, ephemeral local state, scoped server writes, or linkable pre-signup state.
Demo dataSeeded tenant/context, sample project, public catalog, synthetic conversation, tutorial graph, or no seed for pure public browsing.
Upgrade behaviorDiscard anonymous state, link the guest principal to a provider identity, copy selected guest state, or force sign-in before protected actions.
LimitsTTL, rate limits, write quotas, media-upload restrictions, purchase disablement, abuse controls, and cleanup policy.

The implemented runtime path is anonymous-as-user:

POST /auth/anonymous
  -> resolve app-scoped guest principal
  -> create session + rotating refresh token
  -> mint ES256 access token
  -> return the same AuthContextSnapshot + TokenSet shape as sign-in

Recommended token shape:

{
  "sub": "<guest-principal-id>",
  "identity_id": null,
  "principal_type": "user",
  "credential_type": "anonymous",
  "scp": ["profile:read", "content:read"],
  "roles": ["guest"]
}

Why keep principal_type=user for the MVP:

  • AuthMethod.ANONYMOUS already exists in the kernel.
  • The schema already allows principal.identity_id to be null, so no schema rewrite is needed.
  • Existing sessions and refresh-token families are principal-based and can be reused for guest sessions.
  • Downstream BestBuds Workers already accept app users when principal_type=user. Introducing PrincipalKind.ANONYMOUS immediately would force every Worker, route policy, and requirement to learn a new principal kind before the feature is usable.

The server must own the guest authority. The anonymous request chooses the app, environment, optional tenant/context hints, and optional profile metadata. It must not choose roles, scopes, principal id, audience, or raw authority. The runtime assigns configured anonymous roles/scopes/permissions and resolves the membership from server-side policy.

Implemented in source:

  1. AnonymousAuthRequest is part of the common service API.
  2. AuthLogin.anonymous is a first-class capability method.
  3. AuthHttpServiceNode exposes POST /auth/anonymous.
  4. Spring permits /auth/anonymous as a credential-issuing public route.
  5. LoginInteractorNode.anonymous validates app id, resolves the guest principal, checks principal and membership status, creates a session, mints credential_type=anonymous, returns the normal token set, and emits audit.
  6. AuthRuntimeConfig carries default anonymous roles, scopes, and permissions.
  7. Refresh preserves anonymous tokens by inferring a USER principal with identity_id=null and re-minting credential_type=anonymous.
  8. Integration tests assert the anonymous handler exists and basic server calls succeed.

Still open:

  1. Add app-level anonymous policy configuration, either as app config or a dedicated table. The policy should define whether anonymous auth is enabled, which guest/demo flows are exposed, default roles/scopes, demo tenant/context, TTL, write limits, and upgrade behavior.
  2. Include anonymous availability and supported flows in the app bootstrap data so first-launch clients can render the guest/demo entry without hard-coded product logic.
  3. Add AuthAdapter.loginAnonymous(appId, environment, flowId?) for clients.
  4. Update Worker route policy so routes can intentionally allow or reject credential_type=anonymous while still treating the principal as a user.
  5. Implement anonymous-to-provider link/upgrade, including Supabase email upgrade.

Security rules:

  • Rate-limit POST /auth/anonymous per IP/device/app.
  • Audit anonymous mint, refresh, logout, upgrade, and abuse rejection events.
  • Keep guest scopes narrow by default. Public content can be readable; writes, private data, purchases, messaging, and account operations should require explicit route policy.
  • Treat reviewer/demo flows as production behavior. They should be safe, observable, and revocable, not a special bypass that skips normal app policy.
  • Do not use one global demo account. Each guest needs its own principal/session boundary to avoid state leakage and audit ambiguity.
  • Do not let anonymous sessions become a back door into protected app surfaces. Routes should either accept credential_type=anonymous intentionally or require a non-anonymous method.

The upgrade path should be a link operation, not a new unrelated user:

anonymous bearer + provider credential
  -> verify anonymous Reaktor token
  -> verify Google/Apple/Supabase credential
  -> create or resolve identity + provider_account
  -> attach identity_id to the existing guest principal
  -> keep principal id, membership, roles that should survive, and app data

That can later become POST /auth/anonymous/link or an extension of POST /auth/sign-in that accepts the current anonymous bearer. The important property is continuity: any guest-created local state that the app deliberately keeps should move with the same principal when the user becomes registered.

Tests should cover:

  • Anonymous login returns access + refresh and credential_type=anonymous.
  • Refresh preserves anonymous credential type.
  • /auth/session/me reports AuthMethod.ANONYMOUS and identityId=null.
  • Guest scopes cannot be expanded by request input.
  • App bootstrap advertises anonymous availability and supported guest/demo flows.
  • First-launch clients render a visible guest/demo entry when the app policy enables it.
  • Worker policy allows public-content routes and rejects protected routes for anonymous tokens.
  • Link/upgrade preserves the principal id and attaches the real identity.

14. Refresh Flow

Refresh tokens are one-time-use:

fresh rkr_A
  POST /auth/session/refresh
    -> access JWT + rkr_B
    -> rkr_A marked used/rotated

replay rkr_A
  -> family revoked
  -> future rkr_B use fails

This is enforced by SessionRefreshService.rotate(...):

  • Tokens are rkr_ + 32 bytes of secure random data.
  • Only SHA-256 hashes are stored.
  • family_id links all rotations in a session family.
  • Reuse of a used token revokes the family.
  • logout revokes one family.
  • logout-all revokes every active session for the principal.

15. PAT Flow

PATs are exchange credentials, not API bearer tokens.

PAT rkt_...
  -> POST /auth/token grant_type=pat
  -> short-lived access JWT
  -> Worker/server verifies JWT locally

Implemented properties:

  • rkt_ prefix.
  • 32-byte secret body.
  • CRC32 checksum for offline typo/tamper rejection before DB lookup.
  • SHA-256 hash at rest.
  • Optional expiry.
  • Scopes.
  • Optional app binding.
  • Allowed audiences.
  • Revocation.
  • last_used_at metadata.

/auth/pat/mint requires either:

  • REAKTOR_PAT_BOOTSTRAP_TOKEN, or
  • an existing PAT whose scopes include *, auth:*, or auth:pat:mint.

16. Service Account Flow

client_credentials is first-class service-principal auth. It does not fake a user.

{
  "grant_type": "client_credentials",
  "clientId": "messaging-worker",
  "client_assertion": "<jwt>",
  "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
  "audience": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "scopes": ["worker:server"],
  "ttlSeconds": 300
}

The service account authenticates by:

  • PRIVATE_KEY_JWT: preferred. The assertion is verified against service_account.public_jwks.
  • SECRET: supported for bootstrap; secret hash stored server-side.
  • MTLS: reserved for upstream/proxy termination.

Token issuance is audience-bound and down-scoped:

  • If the requested audience is not in allowed_audiences, return unauthorized.
  • If requested scopes exceed the account scopes, only the intersection is granted.
  • Tokens carry principal_type=service and credential_type=client_credentials.

17. Delegation Flow

Delegation uses RFC 8693 token exchange:

{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "clientId": "messaging-worker",
  "client_assertion": "<service assertion>",
  "subjectToken": "<user access token>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "audience": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "scopes": ["worker:server"]
}

The minted token keeps:

  • sub: the user principal.
  • act.sub: the service principal.
  • credential_type: delegation.

This means downstream services can audit both subject and actor. The token shape is implemented and live-tested. Persisted may_act policy is still open, so today the grant is controlled by service-account credentials, allowed audiences, and route-level requirements.

18. Common Task Recipes

The common tasks are intentionally small compositions of the same flows. The top-level API should make the 80% path obvious; the lower-level graph and port surfaces keep the remaining 20% possible without adding route-specific one-shot APIs.

TaskFlows usedResult
Let a user try a Reaktor app before signupAnonymous + refresh + optional linkFirst launch exposes a visible guest/demo entry; the guest principal receives app-configured scopes and can later link to a real identity.
User signs in with Supabase email/passwordSupabase password proof + Reaktor exchangeSupabase verifies the password; Reaktor verifies the provider token and returns Reaktor access + refresh.
User signs in with magic link or email OTPSupabase passwordless start/verify + Reaktor exchangeSupabase delivers and verifies the email proof; Reaktor consumes it once and creates the app-scoped session.
Guest upgrades with Supabase emailAnonymous + Supabase proof + linkThe existing guest principal gains a real identity without losing intentional guest state.
Native app signs in and calls a WorkerUser flowProvider credential becomes a Reaktor access token; Worker verifies locally.
App stays signed in for weeksUser + refreshAccess token stays short-lived; refresh rotates one-time tokens.
Developer CLI or MCP calls Reaktor APIsPAT + token exchangeLong-lived PAT is exchanged for a short-lived access token.
Worker calls another Worker as itselfService accountWorker mints client_credentials JWT and sends it as bearer.
Worker calls another Worker for a userUser + service account + delegationDownstream sees the user subject and the service actor.
Worker calls the graph gatewayService account + graph gatewayWorker uses service JWT over the Workers VPC HTTP binding.
Server handles HTTP auth but keeps policy portableSpring boundary + AuthRequirementSpring authenticates; Reaktor auth authorizes.
Graph-aware module composes auth internallyRuntime graph + secured portsPorts wire only when the current AuthContext satisfies requirements.
Operator investigates auth behaviorAudit flowauth_audit_event shows mint, exchange, refresh, revoke, and failure metadata.

Try an App Before Sign Up

This is the app trial, store-review, and public-content path. The client should show this path on first launch whenever app bootstrap says anonymous auth is enabled.

POST /auth/anonymous
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "flowId": "review-demo",
  "environment": "PROD"
}

The client receives the normal token set:

{
  "tokenSet": {
    "accessToken": "<short-lived-anonymous-reaktor-jwt>",
    "refreshToken": "rkr_...",
    "expiresInSeconds": 900
  }
}

Workers and servers still verify the token locally. Public routes can allow credential_type=anonymous; protected routes can require a signed-in method even when principal_type=user is present. If the user later signs in with Google, Apple, Supabase, or Reaktor credentials, the link flow should attach that identity to the same principal so intentional guest state survives account creation.

The flowId is app-owned policy, not user-owned authority. A BestBuds-style app might expose public-browse, review-demo, and new-user-trial with different demo data and limits. The server resolves that flow to the actual tenant, context, roles, scopes, quotas, and cleanup policy.

Sign In with Supabase Email and Password

The smallest path keeps Supabase SDK work on the client and uses Reaktor only for the provider-token exchange:

client signInWithPassword(email, password)
  -> Supabase access token
  -> POST /auth/sign-in provider=SUPABASE
  -> Reaktor access + refresh
POST /auth/sign-in
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "provider": "SUPABASE",
  "idToken": "<supabase-access-token>",
  "environment": "PROD"
}

The production facade path should be:

POST /auth/email/password/sign-in
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "email": "person@example.com",
  "password": "<user-password>",
  "environment": "PROD"
}

Both shapes return the same Reaktor token set. Workers never accept the raw Supabase JWT.

Passwordless email starts with a policy-checked request:

POST /auth/email/start
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "method": "magic_link",
  "email": "person@example.com",
  "shouldCreateUser": false,
  "redirectUri": "https://bestbuds.ai/auth/callback",
  "environment": "PROD"
}

For OTP, use the same endpoint with method=email_otp, then verify:

POST /auth/email/verify
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "method": "email_otp",
  "email": "person@example.com",
  "otp": "123456",
  "environment": "PROD"
}

For PKCE magic links, the callback carries token_hash and opaque state into /auth/supabase/confirm; Reaktor exchanges it once, verifies Supabase, resolves the principal, and returns or installs a Reaktor session.

Upgrade a Guest with Supabase Email

The client already has an anonymous Reaktor bearer from /auth/anonymous. It then obtains a verified Supabase proof through password, magic link, or OTP and links that proof to the current principal:

POST /auth/anonymous/link
Authorization: Bearer <anonymous-reaktor-access-token>
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "provider": "SUPABASE",
  "idToken": "<supabase-access-token>",
  "environment": "PROD"
}

The link endpoint must preserve the principal id, attach the resolved identity, and mint a non-anonymous Reaktor session. If app policy requires verified email, an unverified Supabase email cannot upgrade the guest.

Sign In and Call a Worker

This is the main app path.

POST /auth/sign-in
Content-Type: application/json
{
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "provider": "GOOGLE",
  "idToken": "<google-id-token>",
  "environment": "PROD"
}

The client receives:

{
  "tokenSet": {
    "accessToken": "<short-lived-reaktor-jwt>",
    "refreshToken": "rkr_...",
    "expiresInSeconds": 900
  }
}

Then the client calls any BestBuds Worker:

GET /topMessages
Authorization: Bearer <short-lived-reaktor-jwt>

The Worker does not call Supabase or the auth server on every request. It uses WorkerJwtVerifier, pins issuer/audience/JWKS, and builds the local user identity from claims.

Keep a Native Session Alive

Mobile and desktop clients keep only the refresh token in secure storage. When the access token is close to expiry:

POST /auth/session/refresh
Content-Type: application/json
{
  "refreshToken": "rkr_A...",
  "environment": "PROD"
}

The response returns a new access token and a new refresh token:

{
  "tokenSet": {
    "accessToken": "<new-access-jwt>",
    "refreshToken": "rkr_B...",
    "expiresInSeconds": 900
  }
}

Clients must replace rkr_A with rkr_B. If rkr_A is replayed, Reaktor treats that as token theft and revokes the whole family. The same session can be introspected later:

POST /auth/session/me
Authorization: Bearer <new-access-jwt>
Content-Type: application/json

Revoke a Lost Device

For one lost device, revoke the refresh-token family represented by that device's refresh token:

POST /auth/session/logout
Content-Type: application/json
{
  "refreshToken": "rkr_device_family...",
  "environment": "PROD"
}

For every device owned by the current principal:

POST /auth/session/logout-all
Authorization: Bearer <current-access-jwt>
Content-Type: application/json

Both paths emit TOKEN_REVOKE audit events.

Give a CLI or MCP Tool Access

The durable developer secret is a PAT, but the API bearer is still a short-lived JWT.

Mint a PAT with an authorized bootstrap token or a PAT that has auth:pat:mint:

POST /auth/pat/mint
Authorization: Bearer <bootstrap-or-authorized-pat>
Content-Type: application/json
{
  "name": "mcp-local-dev",
  "scopes": ["mcp:read", "auth:pat:mint"],
  "appId": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "allowedAudiences": ["manna-mcp"],
  "expiresInDays": 30,
  "environment": "PROD"
}

Exchange the PAT before each API session:

POST /auth/token
Content-Type: application/json
{
  "grant_type": "pat",
  "rawToken": "rkt_...",
  "audience": "manna-mcp",
  "scopes": ["mcp:read"],
  "ttlSeconds": 900,
  "environment": "PROD"
}

The tool sends the returned access token as bearer. This keeps the long-lived PAT out of normal service logs, Worker logs, and graph gateway calls.

Make Worker-to-Worker Calls as a Service

Chat uses this path when it calls messaging. The Worker has Cloudflare secrets:

BESTBUDS_WORKER_SERVICE_CLIENT_ID
BESTBUDS_WORKER_SERVICE_CLIENT_SECRET

The Worker mints a short-lived service JWT:

POST /auth/token
Content-Type: application/json
{
  "grant_type": "client_credentials",
  "clientId": "bestbuds-worker-service",
  "clientSecret": "<cloudflare-secret>",
  "audience": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "scopes": ["worker:server"],
  "ttlSeconds": 300,
  "environment": "PROD"
}

The downstream Worker accepts it only if:

  • principal_type=service.
  • credential_type=client_credentials.
  • aud is the BestBuds app id.
  • scp contains worker:server.

In Kotlin/JS Worker code, the common path is not hand-built JSON. Use the Cloudflare binding:

val workerServiceAuth = serviceAccountAuth(
    clientId = secret("BESTBUDS_WORKER_SERVICE_CLIENT_ID"),
    clientSecret = secret("BESTBUDS_WORKER_SERVICE_CLIENT_SECRET"),
    audience = BESTBUDS_APP_ID,
    scopes = listOf("worker:server"),
)

val headers = mutableMapOf(
    "Authorization" to context.serviceAccountBearer(workerServiceAuth),
)

Call a Worker on Behalf of a User

Use delegation when a service is doing work for a real user and downstream policy/audit must preserve both identities.

  1. The user signs in and calls chat with a user access token.
  2. Chat authenticates as bestbuds-worker-service.
  3. Chat exchanges the user token for a delegated token.
  4. Chat calls messaging with the delegated bearer.
POST /auth/token
Content-Type: application/json
{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "clientId": "bestbuds-worker-service",
  "clientSecret": "<cloudflare-secret>",
  "subjectToken": "<user-access-jwt>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "audience": "38d1cee3-004d-441f-8f66-ad5060859af1",
  "scopes": ["worker:server"],
  "ttlSeconds": 300,
  "environment": "PROD"
}

The downstream token has sub=<user-principal-id> and act.sub=<service-principal-id>. Normal user routes can still read the caller as the user, while audit and delegation-aware policy can see the service actor.

The alternate service-on-behalf-of-user path is the explicit delegated-user header:

GET /topMessages
Authorization: Bearer <service-client-credentials-jwt>
X-BestBuds-Delegated-User: {"id":"<user-id>", ...}

This is accepted only with a valid service JWT. The old static Worker token is not accepted.

Call the Graph Gateway from a Worker

Workers cannot speak Bolt to private Memgraph directly. They call the reaktorServer graph gateway over a Workers VPC HTTP binding, authenticated by the same service-account JWT.

val graphGateway = graph(
    name = "APP_SERVER_HTTP",
    auth = workerServiceAuth,
)

val health = cloudflare.graph(graphGateway) { health() }
val result = cloudflare.graph(graphGateway) { queryId("kg.nodeCount.v1") }

The gateway verifies the bearer locally with JwtVerifier, requires principal_type=service, credential_type=client_credentials, and worker:server, then runs the registered graph operation.

Put Auth Behind Spring Without Making Spring the Model

For JVM servers, Spring owns HTTP interception and Reaktor owns auth semantics. The common route code reads an AuthContext and authorizes declaratively:

val requirement = AuthRequirement(
    permissions = setOf(PermissionRef(name = "messaging:write")),
    principalKinds = setOf(PrincipalKind.USER),
).audience(BESTBUDS_APP_ID)

LocalAuthorizer.authorize(authContext, requirement)

This avoids framework lock-in. The same requirement shape can guard a service handler, a Worker helper, or a graph port.

Compose Auth Inside a Graph-Aware Module

Graph consumers can drop below HTTP and compose auth as capability ports:

class MessagingNode(graph: Graph) : BasicNode(graph) {
    val sessions by consumes<AuthSessions>()
    val tokenGrants by consumes<AuthTokenGrants>()
    val messaging by provides<MessagingPort>(this)
}

For capability-level security, wire with secured ports:

val canSend = AuthRequirement(
    permissions = setOf(PermissionRef(name = "messaging:write")),
    principalKinds = setOf(PrincipalKind.USER, PrincipalKind.SERVICE),
).audience(BESTBUDS_APP_ID)

The graph-level path is the 20% escape hatch: swap a verifier node, mount auth as a child graph, require full wiring, or make an authorization requirement part of the graph edge itself.

Investigate an Auth Incident

Operators should query audit metadata, not tokens:

select event_type, outcome, grant_type, reason, count(*)::int
from heimdall.auth_audit_event
where created_at > now() - interval '30 minutes'
group by event_type, outcome, grant_type, reason
order by event_type, outcome, grant_type, reason;

Examples:

  • Many AUTH_FAILURE rows with grant_type=client_credentials usually means a Worker secret mismatch or a disabled service account.
  • TOKEN_EXCHANGE rows show service-on-behalf-of-user activity. Inspect actor_principal_id, subject_principal_id, audience, and scopes.
  • TOKEN_REVOKE rows confirm logout/logout-all and can be correlated with a user's later refresh failures.

The table intentionally stores request ids, actor/subject ids, scopes, audience, and reason, never raw refresh tokens, PATs, service secrets, or bearer tokens.

19. Graph and Port Auth

Graph consumers have two options.

For the common path, use the high-level runtime graph:

val auth = graph.installAuthRuntime(exposedAdapter, config)
val service = auth.service

For direct graph composition, consume ports:

class Consumer(graph: Graph) : BasicNode(graph) {
    val tokenGrants by consumes<AuthTokenGrants>()
    val sessions by consumes<AuthSessions>()
}

For local auth state and policy, use common graph nodes:

  • AuthGraph.install(graph).
  • AuthNode for AuthContextProvider and AuthContextSink.
  • AuthPolicyNode for AuthPolicy.
  • SecuredProviderPort and SecuredConsumerPort for AuthRequirement-guarded ports.

This lets auth travel with graph causality instead of disappearing at the HTTP edge.

20. Target-Owned Tests

The E2E structure mirrors Maestro: tests live under the target they validate.

tests/
  reaktorServer/
    karate/
    k6/
  notificationServer/
    karate/
    playwright/
  messagingServer/
    karate/
  support/
    karate/

Common commands:

npm run test:auth:karate:smoke
npm run test:auth:karate:live
npm run k6:reaktorServer:auth:smoke
npm run k6:reaktorServer:auth:load

reaktor reaktorServer karate auth smoke
reaktor notificationServer karate auth smoke
reaktor messagingServer karate auth smoke
reaktor reaktorServer k6 auth smoke
reaktor reaktorServer k6 auth load

Live evidence from June 5, 2026:

CheckResult
npm run test:auth:karate:smokePass, 9/9 live smoke scenarios.
KARATE_USE_SERVER_APPLICATION_DB=1 npm run test:auth:karate:livePass, 23/23 live scenarios against real reaktorServer, Supabase, and deployed Workers.
node scripts/run-with-env-files.mjs tmp/karate-fixtures/auth-fixture.env tmp/worker-service-auth.env -- ../reaktor/tools/karate/run-karate.sh --configdir tests/support/karate --tags @service tests/reaktorServer/karatePass, 5/5 live service-account scenarios using the production bestbuds-worker-service credential.
node scripts/run-with-env-files.mjs tmp/karate-fixtures/auth-fixture.env tmp/worker-service-auth.env -- ../reaktor/tools/karate/run-karate.sh --configdir tests/support/karate --tags @delegation tests/messagingServer/karatePass, 2/2 live Worker delegation scenarios using the production bestbuds-worker-service credential.
curl -i https://data-probe.shibasispatnaik.workers.dev/_data/graph/healthPass, HTTP 200 with ok: true, proving Worker -> service JWT -> reaktorServer graph gateway over Workers VPC.
./gradlew :reaktor-auth:jvmTest --console=plainPass, 48 auth JVM tests including audit, refresh, revoke, PAT, service-account, and token-exchange coverage.
npm run k6:reaktorServer:auth:smokeStructurally wired; real run requires local k6 binary.

Karate owns correctness:

  • status and JWKS publication.
  • missing/malformed bearer rejection.
  • invalid refresh rejection.
  • graph gateway service-bearer rejection.
  • user access-token introspection.
  • refresh rotation and old-token reuse rejection.
  • client_credentials minting and audience rejection.
  • delegated token exchange.
  • PAT verify/exchange/token-grant/bootstrap.
  • notification Worker user auth and explicit user mismatch rejection.
  • messaging Worker user auth, service-on-behalf-of-user, and delegated JWT access.

Live Karate does not create a new service account per run. The stable test credential is injected through the environment or an ignored, owner-only tests/reaktorServer/karate/service-auth.env; the seeder upserts the single karate-service account and reuses it. Disposable rows are limited to user/session/refresh-token/role/PAT fixtures because the refresh flow intentionally rotates and replays a token.

k6 owns pressure:

  • low-pressure status/JWKS/auth rejection smoke.
  • explicit request-rate pressure through K6_RPS and K6_DURATION.
  • future spike/soak/SLO budgets.

21. Deployment Status

BestBuds production path currently uses:

  • reaktorServer deployed to k3s.
  • reaktor-auth as source of truth for sessions, refresh tokens, PATs, service accounts, token exchange, and ES256/JWKS verification.
  • BestBuds Workers resolving users from Reaktor access tokens before any legacy development header.
  • Notification and messaging Workers tested through target-owned Karate suites.
  • Auth audit events stored in heimdall.auth_audit_event for mint, exchange, refresh, revoke, and failure flows. Live verification on June 5, 2026 showed recent rows for TOKEN_MINT, TOKEN_EXCHANGE, TOKEN_REFRESH, TOKEN_REVOKE, and AUTH_FAILURE.

Known deployed Worker versions from the rollout:

WorkerVersion
reaktorServerk3s image tag 20260605-1739
chat-service42703fdb-ff53-43f9-87b9-4b8e657ae208
messaging-servicec43238c6-d7c6-4fe8-81dd-3dc49406acb7
social-service009da7cf-2a48-4f34-b416-0e4414ccd850
media-servicee0df6d09-998f-4bcc-a753-fa15ec462d58
sticker-service5f4a95c2-ef6a-4efc-8ac7-ca49d6e23d26
analytics-service2194831e-0310-4978-80e5-d696ae77343b
data-probe699e84f2-7595-413c-90c8-029b255ef45b
notification-servicef4198d6b-6c0b-4f85-875c-01fa777e296e

Worker-to-Worker and Worker-to-reaktorServer calls now use service-account JWTs minted through /auth/token; the static fallback token path has been removed.

Operational commands:

npm run auth:worker-service:provision
npm run auth:worker-service:set-cloudflare-secrets
./gradlew :app:jsBrowserProductionLibraryDistribution --rerun-tasks --console=plain

The first command creates/rotates the bestbuds-worker-service account in Supabase and writes tmp/worker-service-auth.env with 0600 permissions. The second command pushes the client id/secret to Cloudflare Workers and deletes the legacy BESTBUDS_WORKER_SERVICE_TOKEN secret. The Gradle distribution task is required before Worker deploys because the Worker targets import the generated modules/app/bestbuds-kt production library, not just the incremental compileKotlinJs output.

22. Operating Rules

  • Do not add one-shot auth APIs for every route shape. Add a small graph capability or lower-level port and compose it.
  • Do not put auth policy only in Spring Security or only in Worker middleware. Transport boundaries authenticate; AuthRequirement authorizes.
  • Do not use provider tokens as Reaktor API tokens.
  • Do not store raw production refresh tokens, PATs, or service secrets. The Karate service credential must come from the approved secret store or the ignored, owner-only local env file; it is never a versioned fixture.
  • Do not let new tests live in runner-level folders such as tests/karate or tests/k6. Put them under the target.
  • Do not reintroduce the old users / user_role model as runtime auth.
  • When touching hot paths, add a harness or profile artifact before claiming performance wins.

23. Module Review and Assessment

A point-in-time review of the whole module (2026-06-05) across architecture, API and developer experience, security posture, completeness, multiplatform, tests, code quality, and operations. The module is under active development; treat this as a snapshot.

Overall, the core is strong and well-factored: a pure authorization kernel, a graph-native runtime, real external-provider verification, ES256/JWKS, rotating refresh with reuse-detection, and an append-only audit trail. What remains is mostly hardening and breadth — bounding authority on the credential-issuing paths, securing client token storage, finishing browser/web auth, and routine cleanup.

Architecture — strong. The runtime is a graph of twelve nodes (the table in section 07 plus AuthAuditNode) behind a clean dual-surface contract: ordinary consumers use the kernel, the capability interfaces, and the client without ever naming a Graph; graph-aware consumers mount AuthRuntimeGraph, install it under their own graph, or wire to capability ports (section 19). High-level capabilities are graph-free in commonMain; low-level ports are graph-coupled in jvmMain — a deliberate seam. One source-level gap remains: the server's only construction path (AuthRuntimeGraph.create(...)) returns a Graph subtype, and AuthSpringConfiguration reaches into node internals (runtime.jwt.verifier). A small graph-free AuthRuntime facade — an interface aggregating login/pat/sessions/service/verifier/signingKeys, implemented by AuthRuntimeGraph — would let plain servers and the Spring beans depend on a graph-agnostic type. The reaktor-graph artifact dependency itself is expected under the module pattern; the bar is the exposed surface, not a graph-free classpath.

API and developer experience — good, with edges. AuthContext / AuthRequirement / LocalAuthorizer are clean, pure, and portable, and the requirement DSL composes well (anyOf / allOf / forUsers / audience / inApp). The main naming cleanup is now explicit: OAuth/JWT scp/scopes remain wire-format names, but the in-memory authorization model treats scopes and permissions as one capability set (PermissionRef). Remaining rough edges: there is no sugar for multi-role requirements, and audience(...) wraps a bare String the constructor wants as Audience. JS/TS export is partial — the request/response DTOs extend Request / Response (which carry a MutableMap header), forcing @Suppress("NON_EXPORTABLE_TYPE") on LoginRequest / LoginResponse, so JS consumers lean on AuthContextSnapshot plus @JsStatic factories rather than a fully-typed surface. The capability interfaces themselves are clean over DTOs.

Security posture — solid core; hardening tracked privately. Strengths: ES256/JWKS with algorithm pinning and typ=at+jwt, real RS256 provider-signature verification, one-time rkr_ refresh tokens with family reuse-detection, hash-at-rest for every secret, asymmetric-only service-account assertions, and an audit trail that records reasons/IP/UA and never raw secrets. A set of authorization-hardening items — bounding the authority a mint-capable credential can confer, intersecting delegated-token scope with the subject, enforcing principal status on refresh, and making audience-pinning the bearer filter's default — is tracked in an internal review rather than here; none is a remote-unauthenticated break. One client item belongs in the open: the client caches the raw access and refresh tokens in the local object store (SQLite) as plaintext — mobile clients should back that with the platform keystore (iOS Keychain, Android Keystore / EncryptedSharedPreferences).

Use-case completeness.

Use caseStatus
Native/mobile login via Google/Apple ID tokenImplemented
Sessions: create / refresh-rotate / reuse-detect / logout / logout-allImplemented
Access tokens: ES256/JWKS mint + verify, key rotationImplemented
PAT mint / verify / exchange / revokeImplemented (mint authority needs bounding)
Service accounts: client_credentials, private-key-JWT / secretImplemented
RFC 8693 delegation token shapeImplemented (authority-bounding + may_act open)
RBAC, multi-tenant / context, resource checksImplemented in kernel
Audit trailImplemented
Anonymous app trial, store-review, and public-content accessCore implemented; app bootstrap policy, route policy, first-launch UX, demo-flow limits, and upgrade remain open
Supabase email/password + magic link + OTPDesigned here; provider verifier, email facade, callback handling, and tests not implemented
Browser web auth (Auth Code + PKCE, BFF cookies, DPoP)Supabase PKCE callback designed; BFF cookies and DPoP not implemented
Account linking / identity mergeSupabase anonymous-upgrade and link semantics designed; implementation open
Status revocation on refresh / access-token denylistNot implemented
Per-app provider-client binding (auth_provider_client)Not implemented (config-driven)
MFA / step-up / passkeys / rate-limitingNot implemented

Multiplatform and client. The login state machine (AuthLoginState: idle → loading/waiting → signing-in → caching → authenticated/failed) is well-modeled and surfaced as a StateFlow that drives Compose/SwiftUI directly. Google works on Android, iOS, and Web; Apple is native on iOS and Web, but on Android needs an app-level web-OAuth flow (a documented, graceful refusal, not a crash); JVM / desktop is a stub. Two gaps: token storage is plaintext (above), and there is no first-class "restore session from cache" entry point independent of provider registration.

Tests. Server-side coverage is strong — kernel authorization, graph wiring and handler projection, login/token/PAT/refresh/service-account/delegation flows, JWT round-trips with audience pinning and tamper/algorithm-pinning rejection, refresh reuse-detection, and architecture guardrails. Notable gaps: no test drives the graph-free consumer path; the edge WorkerJwtVerifier (crypto.subtle) is untested; the refresh-rotation concurrency window is untested; and the client adapters have no tests.

Code quality. Mostly clean and consistent. Cleanup items: the old LoginInteractor service is vestigial — superseded by LoginInteractorNode, referenced only by its own test; AuthProviderClient is defined twice (kernel and RoleBasedAuth) with field drift (issuer nullability, redirectUris type); the kernel SPI interfaces (Authenticator, TokenIssuer, SessionManager, TokenVerifier, PermissionResolver, TenantResolver) are defined but unimplemented — ship reference implementations or label them explicitly as a future SPI; and error-handling and logging styles differ between the legacy services classes and the runtime nodes.

Operational. The audit trail is a real operational strength. Hardening: signing-key configuration should fail closed in production (the ephemeral-key fallback is silent); the tid (tenant) claim is minted only when present, so the multi-tenancy carrier is otherwise inert (see the reaktor-multitenancy doc); key rotation via config swap (ec-jwk + ec-jwk-prev) is sound.

Prioritized recommendations.

  1. Bound credential-issuance to the caller's authority (PAT mint and token exchange) — specifics in the internal security review.
  2. Enforce principal/membership status on refresh; make audience-pinning the default at the bearer filter.
  3. Add the graph-free AuthRuntime facade plus a guardrail test that drives auth without naming Graph.
  4. Secure client token-at-rest with the platform keystore.
  5. Cleanup: delete LoginInteractor, de-duplicate AuthProviderClient, resolve the kernel SPIs, and refresh the section 07 node table.
  6. Build out the open use cases: Supabase email/password/magic-link/OTP, anonymous app policy and link/upgrade, browser Authorization Code + PKCE / BFF cookies, account linking/merge, and persisted may_act.

24. Open Work

The next increments are:

  1. Supabase provider adapter: UserAuthenticationProvider.Supabase, issuer / audience / JWKS verification, provider-account resolution, and POST /auth/sign-in provider=SUPABASE.
  2. Supabase email facade: start/verify for magic link and OTP, password sign-in, password reset, PKCE/token-hash callback, redirect allowlists, rate limits, and audit.
  3. Anonymous app policy and bootstrap: first-launch entry, app-configured guest/demo flows, per-flow limits, Worker route policy, and link/upgrade.
  4. Browser Authorization Code + PKCE.
  5. BFF __Host- cookie model for web.
  6. DPoP for sender-constrained public-client tokens.
  7. Persisted may_act delegation policy.
  8. ReBAC object tuples for instance-level permissions.
  9. First-class session device/IP/user-agent metadata.
  10. Mobile auth panes backed by live auth event/session APIs instead of fixtures. The desktop pane no longer uses fixtures: it reads heimdall directly through the workbench kernel, described in Workbench Layers. That is schema access, not the auth service's own APIs, so an API-backed pane is still the target for any surface that cannot reach the database — and for anything the schema does not record, such as session device and address metadata (item 9).
  11. Provider authorization (§31): Google and Apple OAuth authorization codes, the provider_grant store, server-side token exchange and refresh, a runtime reader for auth_provider_client, and the provider-side revocation Apple requires on account deletion.

25. Design Review: Heimdall as a General-Purpose Auth Solution

Added 2026-07-01. Sections 01–22 describe how the system works today; §23–§24 assess the code and list open work. This part steps back to a different question: is the underlying model a good basis for a general auth solution that other systems — not just Reaktor's own apps — could adopt? It covers the model verdict, two specific design questions, how Heimdall compares to the wider landscape, and the design changes that would close the gap. It is forward-looking, and in places proposes evolving the model in §04–§05.

Terms used here. Authentication (authn) is proving who a caller is; authorization (authz) is deciding what they may do. An IdP (Identity Provider) logs users in; an AS (Authorization Server) issues tokens; a broker delegates login to an external IdP and issues its own tokens on top. RBAC is role-based access control (permissions through roles); ABAC is attribute/condition-based; ReBAC is relationship-based — "you may edit this document because you own the folder it lives in" — which is Google's Zanzibar model, implemented by SpiceDB, OpenFGA, and Ory Keto. PAT is a personal access token; M2M is machine-to-machine; MFA is multi-factor auth; passkey / WebAuthn is phishing-resistant public-key login; SAML / SCIM are the enterprise single-sign-on and user-provisioning standards.

25.1 Is this a good model?

Yes — and on two axes it is ahead of most commercial products: how it models AI agents (the AGENT and ACTOR principal kinds) and how it models acting-on-behalf-of (RFC 8693 delegation, preserving both actor and subject). The clean separation of identity (the person), provider account (their external logins), and principal (the thing that acts) is the model's biggest strength and matches how the most sophisticated systems are built. The weak points are three: the tenant/context multi-tenancy model (§25.2, §26), authorization that is RBAC-only today (resource-level permissions need a relationship or policy plane — §28), and the absence of first-party login (no passwords, passkeys, or MFA — §27).

25.2 Two model questions

Do you need separate tenant and context? The two ideas are legitimate — even Microsoft Entra keeps "which directory you are in" separate from "which resource a permission applies to." But Heimdall encodes them as two independent optional columns (§04), and that creates an inconsistency: a role grant (principal_role) can be narrowed to a context but never to a tenant. So "Alice is Admin in the Acme tenant" cannot be stated directly — you grant Admin app-wide and re-filter by tenant in application code, or invent a context that stands in for the tenant. By contrast, Auth0 Organizations makes "role R for user U in org O" a first-class, enforced fact. The recommendation is to merge tenant and context into one hierarchical context (§26).

Why are roles app-scoped, not context-scoped? This part is correct and worth keeping. "Define a role once per product/app surface, then grant it in a specific place" is what Auth0, Okta, Azure, and Keycloak all do; defining roles per-context would explode the catalog and lose reuse. The role definition stays audience/product-scoped; the grant carries the context.

25.3 How Heimdall compares to the landscape

In shape, Heimdall is a self-hosted, database-first system in the Keycloak/Ory family. It is ahead on agents and delegation, even on token handling, and behind on first-party login, fine-grained (relationship-based) authorization, and enterprise SSO.

ProductIdentity / principal modelMulti-tenancyAuthorizationAgents / M2M / delegationFirst-party login + MFAShape
Heimdallidentity + typed principal (user/service/agent/actor) — strongapp + tenant + context (two-axis, weak)RBAC + scopetoken-exchange / delegation — strongnoneself-hosted, DB-first broker + AS
Auth0user + linked identitiesOrganizationsRBAC + FGA (add-on)M2M appsyes (+ passkeys, MFA)SaaS
Oktauser + factorsorgs / groupsgroups + policiesservice appsyes, + SCIM/SAMLSaaS, enterprise
Keycloakuser + federationrealmsrealm/client roles, groupsclient service accountsyesopen-source, self-host
Cognitouser poolspools (≈ tenant)groups (weak)app clientsyes (basic)AWS
Clerkuser + orgsOrganizationsorg roles / permissionslimitedyes (passkey-first)SaaS, strong DX
WorkOSuser + orgsOrganizationsRBAC + FGAyesvia AuthKit, + SAML/SCIMSaaS, enterprise-readiness
Stytchmember / orgOrganizations (B2B)RBACM2Myes (passkeys/OTP)SaaS, B2B
OryKratos identitiesnamespacesKeto = Zanzibar ReBACHydra (OAuth AS)Kratos flowsopen-source, composable
SpiceDB / OpenFGA— (authorization only)namespacesReBAC only——authorization engine
Entra IDidentitiesdirectory tenant ≠ RBAC scope treeRBAC with scope hierarchyservice principals / managed identitiesyescloud
Supabase Authusersnone (row-level security)RLS in the databaseservice-role keyyes (basic)DB-centric

The reading: Heimdall's durable edge is the agent/delegation model and the graph integration (§28). The gaps to close for general adoption are first-party credentials + MFA (§27), a relationship/policy authorization plane (§28, and the "ReBAC object tuples" already in §24), the tenant/context model (§26), and enterprise SAML/SCIM (§24).

26. Unified Context: resolving tenant vs. context

A proposed evolution of the §04 model; pairs with the multitenancy doc.

In plain terms: instead of separate tenantId and contextId fields that do not compose, model one hierarchical context tree. A tenant is a context near the top. An app surface is also a context. A concrete resource is a context near the bottom. A role granted at any context applies to that context and, when allowed by policy, to everything beneath it.

This supports two important cases without building separate auth systems:

  • One product, many app surfaces. A product like Swiggy can have one product root context with child contexts for customer, driver, restaurant, darkstore, support, and internal operations. Each surface can have its own audience, roles, service accounts, and policies, but identities, grants, audits, and tokens stay in one auth system instead of being duplicated per app.
  • Authority at any hierarchy level. A restaurant owner can be admin for a restaurant context, a manager for one outlet, an operator for a menu section, and a user can be admin of their own posts without being admin of someone else's posts. The grant attaches to the context node where authority starts; resource checks resolve whether the requested resource is inside that subtree.

audience and context are deliberately different. audience says who the token is for: swiggy.restaurant-api, swiggy.driver-api, reaktor.workbench, etc. context says where the caller's authority applies inside the product/resource hierarchy. New tokens should not need a separate token-facing appId claim; the audience is the app/surface namespace.

create table heimdall.context (
  id uuid primary key default gen_random_uuid(),
  audience varchar(160) not null,       -- product or app-surface namespace
  parent_id uuid references heimdall.context(id) on delete cascade,
  kind varchar(32) not null,            -- 'PRODUCT' | 'APP_SURFACE' | 'TENANT' | 'RESOURCE' | ...
  key varchar(120) not null,
  name varchar(100) not null,
  status varchar(50) not null default 'ACTIVE',
  path  ltree,                          -- materialized path, so "is X under Y?" is one indexed lookup
  unique (audience, parent_id, key)
);

-- Grants, sessions, memberships, PATs, and service accounts each reference one context_id.
-- The new context_id replaces the old tenant_id + context_id split.
alter table heimdall.principal_role        add column context_id uuid references heimdall.context(id);
alter table heimdall.membership            add column context_id uuid references heimdall.context(id);
alter table heimdall.session               add column context_id uuid references heimdall.context(id);
alter table heimdall.personal_access_token add column context_id uuid references heimdall.context(id);
alter table heimdall.service_account       add column context_id uuid references heimdall.context(id);

A role granted at context C then satisfies a requirement at C or anything beneath C — the same inherited model Azure RBAC and Zanzibar use. The ltree path makes "is this required context under that granted context?" a single indexed comparison rather than a recursive query per check. In the kernel (AuthContext / AuthRequirement), tenantId disappears and the existing contextId becomes the one canonical authority location, with a resolved contextPath. LocalAuthorizer allows the request when the grant's context path is a prefix of the required resource context path.

This makes tenant isolation engine-enforced rather than application-enforced (today §05 notes isolation rests on "AuthContext plus resource checks"), removes the tenant/role asymmetry from §25.2, gives the relationship plane in §28 its hierarchy for free, and still scales down cleanly for a simple app: create one product root context and grant there. Migration is intentionally not part of this implementation pass: add the new context table, backfill product/app-surface/tenant/resource contexts from existing tenant and context values, dual-write for one release, switch reads over, then drop tenantId and legacy duplicate context encodings.

27. First-Party Credentials and MFA

The §23/§24 status lists MFA, passkeys, and Reaktor-native email login as not-yet-implemented or Supabase-delegated. This section sketches the first-party design — the path from "broker" (§12, where Supabase owns the email credential) to a self-contained IdP.

Today login happens only through provider_account (federation), plus PATs and service accounts. The AuthMethod enum already has the slots; the missing piece is the credential stores:

heimdall.password_credential   (id, identity_id fk, hash /*argon2id*/, algo, rotated_at, breached_at, ...)
heimdall.webauthn_credential   (id, identity_id fk, credential_id bytea unique, public_key, sign_count,
                                transports, aaguid, nickname, created_at, last_used_at)        -- passkeys (FIDO2)
heimdall.mfa_factor            (id, identity_id fk, type /*TOTP|SMS|WEBAUTHN*/, secret_ref /*Secret Manager*/,
                                status, verified_at)
heimdall.verification_token    (id, identity_id fk, purpose /*email|phone|password_reset|magic_link*/,
                                token_hash, expires_at, used_at)
heimdall.recovery_code         (id, identity_id fk, code_hash, used_at)

-- step-up / assurance: record HOW and HOW STRONGLY a session authenticated
alter table heimdall.session add column amr text[] default '{}';   -- e.g. ['pwd','otp','webauthn']
alter table heimdall.session add column acr varchar(32);           -- assurance level, e.g. 'aal2'

AuthContext already records the auth method; adding amr/acr lets a requirement demand step-up (e.g. AuthRequirement.requiresAcr("aal2") for sensitive actions). New AuthService operations follow the existing facade shape: registerPassword, beginPasskey/finishPasskey (the WebAuthn handshake), enrollTotp/verifyTotp, and the email/OTP flows already designed in §12. A brute-force lockout (a failed-attempt counter, or derived from auth_audit_event over a sliding window) completes it.

Crucially, the Reaktor rule from §12 still holds: a credential proves a login; Reaktor still owns principal, membership, session, tokens, authorization, and audit. The first-party layer simply means Reaktor can be the credential store (passkeys, password, MFA) instead of always delegating to Supabase or a social provider — which is what "auth for most systems" requires.

28. Graph Amplification: auth that travels with causality

Extends §19. The thesis: running auth inside the Reaktor graph is not just convenient wiring — the graph supplies authorization capabilities a standalone library cannot.

  • The component tree can double as the permission hierarchy. Reaktor already arranges an app as a tree of nodes. If each node also stands for a "place" in the context hierarchy (§26), then granting a role on a parent node automatically grants it on everything beneath — exactly the inherited, relationship-based authorization plain RBAC lacks. A Zanzibar-style system needs a graph of objects to reason over; reaktor-graph already is that graph. The kernel's AuthRequirement already carries a resource reference and anyOf/allOf composition (RBAC plus attribute-style conditions); what is missing is a relationship backing for the resource seam, and the graph supplies it. This is the same capability §24 tracks as "ReBAC object tuples."
  • ServiceNode makes authorization part of typed routing. Each request handler is already exposed as a port; attaching an AuthRequirement to the handler means every call is checked uniformly, declared once, on both client and server (today via AuthRequirementInterceptor).
  • SecuredProviderPort / SecuredConsumerPort are capability-based security wired into dependency injection (§19) — a dependency edge that only connects when the caller satisfies a requirement. Very few frameworks have this. The gap: enforcement is currently manual (enforceConnectionSecurity()); making it automatic at wire time would flip the default from "open unless you remember to check" to "closed unless explicitly opened."
  • ActorNode plus AGENT/ACTOR principals plus token-exchange give first-class authorized agents. Run an actor node under a delegated AuthContext (the user's token exchanged with the agent's service identity) and every message it processes is attributed, serialized, and auditable as "agent acting for user" — the same subject/actor pair the audit trail already records (§17).
  • Ambient AuthContext via ports: any node can consume the current caller, typed, without threading it through every call.
  • Aspirational: route guards (an AuthRequirement on a navigation node), and a workbench view that renders the authorization model as a live graph — feasible because scopes, grants, and secured ports are all graph objects.

Standalone, Heimdall is a Keycloak/Ory-class library (kernel + interceptors + client). Inside the graph, the component tree becomes the permission hierarchy, dependency wires become capability-secured, AI agents become delegated actor nodes, and the authorization model becomes inspectable. Most of the wiring exists (§19); the high-value next steps are automatic secured-port enforcement, letting graph topology supply the context hierarchy (§26), and route guards.

29. Use-Case Archetypes

A general auth solution has to serve more than one shape of system. These archetypes are the test set: if the model serves all of them, it serves most systems.

ArchetypePrincipal kindsTenancyAuthorization needWhat it stresses
Consumer social (the reference app)USER, AGENT (bots), SERVICE (workers)single app, flatsimple roles plus per-resource grants (per-chat / per-group)resource-level authorization, agent principals, social login
Developer platformUSER (devs), SERVICE (CI), AGENT (tools), PATsmulti-appPATs, service accounts, app-scoped adminmachine-to-machine, PAT lifecycle, multi-app isolation, delegation
Personal / single-owner appUSER (owner), AGENT (AI assistant)personal, light sharingowner-has-all plus per-workspace shared scopedelegation, scope-per-workspace, share links
Regulated B2B commerceUSER (org members), SERVICE (integrations), ACTOR (delegated ops)multi-tenant organizationsorg roles plus resource-level plus relationship-based plus immutable auditeverything at once — the forcing function
Internal back-officeUSER (employees), SERVICEdepartmental scopesemployee roles, department-scoped, internal SSOinternal RBAC, single sign-on
Third-party SaaS customersall of the abovehard per-customer isolationfull IdP (passkeys/MFA), SAML/SCIM, organizationsthe global-identity assumption breaks

The forcing function is regulated B2B commerce (multi-tenant organizations with financial and audit obligations): it needs the context hierarchy (§26), relationship-based visibility (§28), an immutable tamper-evident audit trail, delegation (an operator acting for an account), and strong auth / MFA (§27) — all at once. The consumer-social archetype exercises resource-level grants and agent principals; the developer-platform archetype exercises PATs, service accounts, and multi-app isolation; the personal-app archetype exercises agent delegation and per-workspace contexts. The one assumption to revisit before serving isolated third-party customers is global identity (§04, §25.1): one identity shared across apps suits a single-owner multi-app platform, but not customers who each require a sealed user pool.

30. Perspective Reviews

Security-threat, compliance, developer-experience, and performance lenses. Complements the code/security assessment in §23.

30.1 Threat model (STRIDE)

ThreatWhat is defendedWhat is still open
Spoofing (faking identity)JWT verification (signature + audience + expiry); typed principalsthe dev-only header path (ALLOW_USER_HEADER_AUTH, §10) must be provably off in production
Tampering (altering tokens)signed ES256 tokens; refresh-token rotation with revoke-on-reusetokens are not yet sender-bound (DPoP/mTLS, §24), so a stolen token is usable
Repudiation (denying an action)append-only audit log with actor vs. subjectretention and tamper-evidence (a hash chain) for financial use
Information disclosuresecrets stored as hashes or references, never rawkeep security-critical claims/PII in typed columns, not free-form JSON
Denial of serviceper-role statement timeoutsno rate-limiting or account lockout primitive yet
Elevation of privilegeLocalAuthorizer capability/role checks; secured portstenant isolation is application-enforced until §26; secured-port enforcement is manual until auto-enforce (§28)

The token-theft posture is strong (short-lived access tokens, rotating refresh tokens, reuse detection). The remaining work is token binding, rate-limiting/lockout, and secure-by-default enforcement.

30.2 Compliance

  • SOC 2 / ISO 27001: the audit trail is in place; add a retention policy, periodic access reviews (exporting who-has-what), and break-glass logging.
  • GDPR: right-to-erasure means deleting an identity must cascade to its provider accounts, memberships, and sessions; add per-context data residency once the unified context hierarchy is real; add consent records if Heimdall ever issues tokens to third-party apps.
  • Regulated / financial: needs an immutable, tamper-evident audit log (it is append-only today; add a hash chain), separation of duties via roles, strong auth / MFA (§27), and KYC linkage through provider accounts.

30.3 Developer experience

Two on-ramps, matching the §01 design contract: simple — an AuthServiceClient plus permits("capability"); powerful — compose an AuthRequirement and gate ports with providesSecured, or mount the runtime with AuthGraph.install(graph). The single most valuable improvement is secure-by-default: today a developer must remember to add the interceptor or call enforceConnectionSecurity(); making enforcement automatic (§28) flips the default from "open unless gated" to "gated unless opened."

30.4 Performance and scale

Identity resolution is the hot path, so building the AuthContext must stay cheap: verify JWTs statelessly at the edge (no database round-trip, §10), cache the verification keys (JWKS), and cache the resolved context on the client. Keep context ancestry checks cheap by resolving "is X under Y" through a materialized path (ltree) or closure table rather than a recursive query per check (§26). Keep tokens small: prefer audience-bound tokens with server-side fine-grained checks over inlining an unbounded permission set into every JWT.

30.5 Build vs. buy

Extend Heimdall for graph-native capability security, agent delegation, self-hosting, and Kotlin-multiplatform reach. Reach for Auth0 / Okta / WorkOS when you need turnkey enterprise SSO, SCIM, and a connection marketplace today. The agent/delegation model and the graph amplification are the parts that are not commodities.

31. Provider Authorization: Google and Apple OAuth

Design snapshot: 2026-08-27. Provider authentication resolves a credential to a Reaktor principal. Provider authorization obtains and holds a grant for API calls on the user’s behalf. At this snapshot, reaktor-auth authenticates Google and Apple identities but does not implement provider API authorization; the following design maps the remaining work to the existing schema.

31.1 The gap: authentication is not authorization

Reaktor asks Google and Apple exactly one question: who is this? It receives an ID token — a signed assertion about identity — verifies it against the provider's JWKS (§06), resolves a principal, and discards it. It never asks the second question: which actions are authorized on your behalf?

That second question is answered by an OAuth authorization code, exchanged server-side at the provider's token endpoint for an access token, a refresh token, and a set of granted scopes. reaktor-auth has no code path that requests one, no wire field that carries one, no repository that stores one, and no service that refreshes one.

The distinction bites the moment a product wants to read the user's Drive, list their Calendar, send through Gmail, or comply with Apple's account-deletion rule. An ID token does none of those. It is login proof — not a bearer token for provider APIs. This is precisely the boundary §12 draws for Supabase, applied in the other direction.

One clarification, because the vocabulary collides. §11 and §24 already list "Browser Authorization Code + PKCE" as open work; that item is about Reaktor's own browser login becoming a proper code flow. This section is about the provider's code flow. They are independent: either can land first, and both use PKCE for the same reason.

31.2 What the six client surfaces already offer

ProviderPlatformSDK in useCaptured todayAuthorization is reached byDistance
GoogleAndroidGetSignInWithGoogleOption (Credential Manager)idTokena separate AuthorizationClient call (Play Services Identity) returning serverAuthCode + granted scopesnew SDK surface
GoogleiOSGIDSignInidTokenaddScopes(...), then user.serverAuthCode — which requires serverClientIDalready configured, code never read
GoogleWebgoogle.accounts.id (One Tap)credential (ID token)google.accounts.oauth2.initCodeClientdifferent GIS namespace, not yet bound
AppleiOSASAuthorizationAppleIDProvideridentityTokenASAuthorizationAppleIDCredential.authorizationCodesame object already being read
AppleWebAppleID.authauthorization.id_tokenauthorization.codesame object, field already declared
AppleAndroidnone—web flow in a Custom Tab returning code to a deep linkAndroidAppleLogin is a fail() stub

The encouraging read: on three of the six surfaces the authorization code is already sitting in an object the code parses and throws away. DarwinAppleLogin reads credential.identityToken and ignores credential.authorizationCode. WebAppleLogin reads authorization.id_token while AppleAuthorization.code is declared, typed, non-null, and unused. DarwinGoogleLogin already accepts and configures serverClientID — the only reason it produces no serverAuthCode is that nothing asks for it.

The remaining work is plumbing and a server-side exchange, not new provider integrations.

31.3 What the schema already anticipated

heimdall.auth_provider_client (AuthTables.kt) already exists and already carries the full OAuth client registration for a code flow:

app_id · provider · platform · client_id · client_secret_ref
issuer · apple_team_id · bundle_id · redirect_uris · enabled

That is the config half, correctly shaped: keyed per (app, provider, platform), holding a reference to the client secret rather than the secret, with a redirect-URI allowlist and Apple's team/bundle identifiers — which are exactly the inputs to Apple's client-secret JWT (§31.4).

It is also entirely inert. AuthProviderClients appears in the table list, has a DTO — declared twice, in both RoleBasedAuth.kt and kernel/AuthKernel.kt, which should be reconciled — and is created by the test fixture. No repository reads it and no interactor uses it. Provider verification today runs off statically constructed UserAuthenticationProvider.Google/Apple audiences in Authentication.kt, not off this table.

So the gap is narrower than §31.1 suggests: the client-registration model is designed and migrated. What is missing is the grant, the exchange, and the runtime that reads the registration.

31.4 Apple's sharp edges

Sign in with Apple is not Google with different branding. Three of its behaviours cause permanent data loss or scheduled outages if a design ignores them.

Name and email arrive exactly once. Apple returns fullName and email only on the first authorization for an app. Every later sign-in returns an identity token carrying a sub and nothing else. DarwinAppleLogin reads it.fullName?.givenName on every login, so a user who revokes and re-authorizes becomes permanently nameless. The fix is at the resolution layer, not the client: persist the name on the first provider_account insert and never overwrite it with a later blank.

The client secret is a short-lived JWT, not a string. Apple's token endpoint expects an ES256 JWT signed with a .p8 key, iss = team ID, sub = the service/bundle ID, aud = https://appleid.apple.com, valid for at most six months. It must be minted, cached, and rotated — SigningKeys plus reaktor-secrets work. apple_team_id and bundle_id are in auth_provider_client for this reason. Any design that treats APPLE_CLIENT_SECRET as a static string has a scheduled outage in it.

Revocation is a compliance requirement, and Reaktor currently cannot meet it. Apple requires apps offering account deletion to call the token revocation endpoint — which requires having kept the refresh token. AuthAdapter.deactivateAccount revokes Reaktor sessions and soft-deletes the principal, but cannot revoke at Apple, because §31.1 means no provider refresh token was ever stored. This is the one item on the list with an external deadline attached.

Also worth recording: @privaterelay.appleid.com addresses only forward from domains registered with Apple, are per-app, and vanish when the user revokes. Store the relay address as the provider_account email, but treat it as non-canonical for anything that matters.

31.5 Model addition: the provider grant

provider_account (§04) records who the user is at a provider. It has no column for what the user let us do there — and should not gain one, because grants are per-app, independently revocable, and have a lifecycle of their own:

heimdall.provider_grant (
  id                  uuid primary key,
  provider_account_id uuid not null references heimdall.provider_account(id) on delete cascade,
  app_id              uuid not null references heimdall.app(id),
  scopes              text[]      not null default '{}',
  access_token_ref    text,           -- Secret Manager reference, never the token
  access_expires_at   timestamptz,
  refresh_token_ref   text,           -- ditto; Google issues this only with access_type=offline
  status              varchar(20) not null default 'ACTIVE',   -- ACTIVE | EXPIRED | REVOKED
  granted_at          timestamptz not null default now(),
  last_refreshed_at   timestamptz,
  revoked_at          timestamptz
  -- plus the auditable mixin
);
create unique index on heimdall.provider_grant (provider_account_id, app_id);

The _ref columns are the load-bearing detail. §30.1 already records the rule — "secrets stored as hashes or references, never raw" — and a provider refresh token is the most dangerous secret this system can hold: it outlives every Reaktor token, grants access to systems Reaktor does not control, and cannot be rotated by Reaktor. It belongs in Secret Manager via reaktor-secrets, with the row holding only the reference, exactly as auth_provider_client.client_secret_ref already does.

One enum addition, following the existing shape:

  • AuthCredentialType.PROVIDER_GRANT("provider_grant"), so grants and revocations appear in auth_audit_event beside token mints.
  • No AuthMethod addition. A provider grant is not a way to authenticate to Reaktor. Keeping it out of AuthMethod is what prevents the two concepts from collapsing back together.

31.6 Port and service shape

Follows the §07 decomposition — one capability port in commonMain, one node in the JVM runtime graph, one interactor with a strategy per provider:

// commonMain — graph-agnostic capability port (module layering rule)
interface AuthProviderGrants {
    suspend fun exchange(request: ProviderGrantRequest): ProviderGrantResult
    suspend fun grant(principalId: String, provider: AuthProviderKind, appId: String): ProviderGrant?
    suspend fun accessToken(grantId: String): String?    // refreshes inside the skew window
    suspend fun revoke(grantId: String): Boolean         // revokes at the provider, then marks the row
}
  • AuthProviderGrantNode (jvmMain) mounts it into AuthRuntimeGraph next to AuthTokenGrantNode.
  • ProviderGrantInteractor performs exchange and refresh through a per-provider strategy: GoogleTokenEndpoint, and AppleTokenEndpoint wrapping the ES256 client-secret minter from §31.4.
  • AuthProviderClientRepository finally gives auth_provider_client (§31.3) a reader, and Authentication.kt should source its audiences from it rather than from static construction.
  • accessToken() mirrors AuthAdapter.sessionHeaders(): serve the cached token if it survives the 60-second skew, otherwise refresh, otherwise mark the grant EXPIRED and return null so the caller can prompt for re-consent.
  • TokenRefreshWorker (reaktor-work) exists only as a placeholder shape for proactive refresh. Scheduled refresh is an optimization: the owning auth service must still check expiry and serialize refresh at use time. Device background execution cannot guarantee provider-grant availability.

HTTP surface (§08):

POST /auth/provider/authorize        { appId, provider, authorizationCode, redirectUri?, codeVerifier? }
GET  /auth/provider/grants           -> the caller's grants: provider, scopes, status. Never tokens.
POST /auth/provider/grants/{id}/revoke

Note the asymmetry that keeps this safe: the provider access token never travels back to the client. The client asks Reaktor to act with a grant; it does not receive the grant. A compromised device then costs the attacker a short-lived, revocable Reaktor session rather than standing access to the user's Google account. A product that genuinely needs a client-side provider token — a browser mounting the Drive picker, say — should request it from the provider directly with a narrow scope and keep it out of Reaktor entirely.

31.7 Client surface changes

The wire type gains one optional field rather than a parallel type, so the existing login path is untouched when nothing requests authorization:

interface AuthProviderUser {
    val idToken: String
    val authorizationCode: String?    // new: non-null only when scopes were requested
    // ...
}

Provider authorization becomes an optional capability a provider may implement, rather than a parameter every provider must accept:

interface ProviderAuthorization {
    val supportedScopes: Set<String>
    suspend fun authorize(scopes: Set<String>): Result<ProviderAuthorizationCode>
}

AndroidGoogleLogin, DarwinGoogleLogin, WebGoogleLogin, DarwinAppleLogin, and WebAppleLogin implement it; anything that cannot do it simply cannot be asked, and the type system says so. This holds the §01 contract: the 80% path (login()) does not grow a scopes parameter, and the 20% path stays reachable by asking a provider for the capability.

Incremental (scope-up) authorization should be the default: sign the user in with no scopes, then request Drive or Calendar at the moment the feature is first used. Google's consent screen rewards this, and it keeps a first-run login from presenting a wall of permissions the user has no context for.

31.8 Flow

 1  User already has a Reaktor session (§11)
 2  A feature needs provider scope S
 3  Client calls ProviderAuthorization.authorize(setOf(S))
 4  Provider SDK returns an AUTHORIZATION CODE (not an access token)
 5  Client POSTs /auth/provider/authorize with the code and its Reaktor bearer
 6  AuthProviderGrantNode verifies the caller and resolves the provider account
 7  ProviderGrantInteractor reads auth_provider_client, exchanges the code at the
    provider token endpoint (Apple: minting the ES256 client secret first)
 8  Access + refresh tokens go to Secret Manager; provider_grant stores refs + scopes
 9  auth_audit_event records the grant — provider, app, scopes; never the tokens
10  Server-side features call AuthProviderGrants.accessToken(grantId) and act as the user

31.9 Security rules

Mirrors §12's rules, in the other direction:

  1. A provider grant never authenticates to Reaktor. Holding a Google access token proves nothing about a Reaktor principal.
  2. Provider refresh tokens live in Secret Manager, referenced from provider_grant — never in a database column, never in a log, never in an audit payload.
  3. Exchange is server-side only. The client sends a code; the client never sees the resulting tokens.
  4. PKCE on every browser and native code flow, with the auth_provider_client.redirect_uris allowlist enforced on every exchange.
  5. Scopes are recorded, capped per app by configuration, and audited. An app may not exchange a code for scopes it is not configured to hold.
  6. Account deactivation must revoke at the provider, not merely mark rows — required by Apple (§31.4), courteous with Google.
  7. Grants are per (identity, app), never global. §29's caveat about the global-identity assumption applies with force here: one identity spanning apps must not mean one Drive grant spanning apps.

31.10 Increments

  1. Carry authorizationCode on AuthProviderUser and read it where it is already free — ASAuthorizationAppleIDCredential.authorizationCode and Apple web authorization.code. No new SDK surface; unblocks Apple revocation compliance.
  2. provider_grant table, AuthProviderGrants port, AuthProviderGrantNode, AuthProviderClientRepository, and the Google token-endpoint strategy.
  3. Google scope-up across the three client surfaces: Android AuthorizationClient, iOS addScopes, web initCodeClient.
  4. Apple token endpoint with the ES256 client-secret minter and rotation, plus first-authorization name capture (§31.4).
  5. POST /auth/provider/authorize, grant listing, revocation, and wiring revocation into account deactivation.
  6. Proactive refresh via TokenRefreshWorker, the EXPIRED transition, and a re-consent signal to the client.
  7. AndroidAppleLogin via Custom Tab plus deep link, closing the stub.
  8. Reconcile the duplicated AuthProviderClient DTO (§31.3) and move Authentication.kt audiences onto the table.

Steps 1 and 2 are load-bearing; everything after is a strategy or a surface on top of them.

  • Security Plan - Use before enabling deploy actions, remote agents, or privileged commands.
  • Multitenancy - Use before designing tenant isolation, adding a tenant-scoped store, wiring the tid claim, or building per-tenant quotas/residency/lifecycle; pairs with reaktor-server (high-scale tenancy) and auth-analysis (the membership model).
  • Reaktor Secrets - Use before moving, rotating, distributing, caching, or materializing secrets.
  • Deep Links - Use before adding route-driven external entry points.
  • Service Layer - Use before adding service APIs, splitting reaktor-service out of reaktor-graph, wiring handlers to Spring/Workers, or making modules graph-projectable without hard graph dependencies.
  • DevOps - Use before changing the build/deploy/ops pipeline, the Kotlin platform model, deployment partitioning, gated deploy operations, or the reaktorDesktop Deploy pane; pairs with reaktor-cloud-pane (operator view) and secrets-management.