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:
- Client calls
AuthAdapter.login(...)or a service calls/auth/token. - The runtime resolves a principal and mints a short-lived Reaktor token.
- Servers and Workers verify the token locally.
- Handlers, graph ports, and actors authorize against
AuthContextplusAuthRequirement.
The remaining 20% path must still be possible:
- Graph consumers can mount
AuthRuntimeGraphand wire directly to capability ports such asAuthLogin,AuthPat,AuthTokenGrants, andAuthSessions. - Infrastructure consumers can drop down to lower-level ports such as
AuthPrincipalDirectory,AuthPersonalTokens,AuthSessionLifecycle, andAuthServiceAccounts. - Frameworks stay adapters. Spring and Cloudflare do not own the auth model; they translate transport credentials into graph/kernel primitives.
02. Current Status
| Area | Status | Notes |
|---|---|---|
| Graph runtime | Implemented | AuthRuntimeGraph is the JVM composition root and a real child-capable Graph. |
| Kernel authorization | Implemented | AuthContext, AuthRequirement, and LocalAuthorizer are pure commonMain primitives. |
| Identity model | Implemented | identity, provider_account, principal, membership, tenant/context, RBAC, sessions, PATs, service accounts, signing keys. |
| User login | Implemented server side | External ID token -> principal/membership -> session -> rotating refresh + ES256 access token. |
| Anonymous login | Implemented core, policy hardening open | AuthLogin.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 rotation | Implemented | One-time-use rkr_ refresh tokens, SHA-256 at rest, family reuse detection. |
| Access tokens | Implemented | ES256, typ=at+jwt, JWKS, issuer/audience/expiry validation. |
| PATs | Implemented | rkt_ high-entropy token with offline checksum, hash-at-rest, expiry, scopes, app/audience binding. Legacy rak_ verifies for compatibility. |
| Service accounts | Implemented | client_credentials, private key JWT or secret auth, audience binding, scope down-scoping. |
| Delegation | Implemented token shape | RFC 8693 token exchange with user subject and service actor in act. Persisted may_act policy is still future work. |
| Auth audit | Implemented | auth_audit_event records token mint, exchange, refresh, revoke, and auth failure events without raw tokens or secrets. |
| Spring adapter | Implemented | SpringDependencyAdapter creates the runtime; WebFlux filter verifies tokens and exposes AuthContext. |
| Worker auth | Implemented in consumers | BestBuds Workers verify Reaktor JWTs through JWKS. Worker-to-Worker and Worker-to-server calls use service-account JWTs, not static fallback tokens. |
| Target-owned E2E | Implemented | Karate lives under tests/<target>/karate; k6 under tests/<target>/k6. |
| Supabase email/password, magic link, and OTP | Designed here, not implemented | AuthProviderKind.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 / DPoP | Not implemented | Native/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 tuples | Not implemented | Kernel 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:
ReaktorSecurityWebFilterinside a JVM/Spring server.WorkerJwtVerifierinside 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".
| Concept | Table / type | Meaning |
|---|---|---|
| App | app | Product/application namespace. Roles, permissions, contexts, tenants, sessions, and credentials are app-scoped. |
| Identity | identity | Global human identity. A person can have many provider accounts and app memberships. |
| Provider account | provider_account | External login method attached to an identity: Google, Apple, Supabase, or Reaktor. Unique by provider, issuer, subject. |
| Principal | principal | Authorizable 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. |
| Membership | membership | Principal membership in an app, tenant, and optional context. |
| Tenant | tenant | Organization/unit inside an app. |
| Context | context | App-local authorization scope for a resource, workflow, tenant slice, or graph context. |
| RBAC | role, permission, role_permissions, principal_role | App-scoped role/permission grants, optionally context-scoped. |
| Session | session | Principal login session. Refresh-token families are anchored here. |
| Refresh token | refresh_token | One-time-use rotating token family. Reuse revokes the family. |
| PAT | personal_access_token | Long-lived secret handle exchanged for short-lived JWTs. Raw token is shown once; only hash is stored. |
| Service account | service_account | Machine credential for client_credentials and delegated token exchange. |
| Signing key | signing_key | Public signing-key metadata and private-key reference. Runtime private keys come from config/secrets. |
| Auth audit event | auth_audit_event | Append-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:
- Missing auth context -> 401.
- Audience/app/tenant mismatch -> 401 or 403.
- Wrong principal kind -> 403.
- Missing scope, permission, or role -> 403.
- Delegated call without
allowDelegation-> 403. - 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
| Credential | Format | Storage | Use |
|---|---|---|---|
| External provider token | Google/Apple/Reaktor provider token | Not trusted as Reaktor API token | Login proof only. |
| Reaktor access token | ES256 JWT, typ=at+jwt, kid, JWKS | Client memory / short-lived bearer | API, Worker, graph, and service calls. |
| Refresh token | rkr_ opaque token | SHA-256 hash in refresh_token | User session rotation. One-time-use. |
| PAT | rkt_ opaque token with checksum | SHA-256 hash in personal_access_token | Developer/tool/CI bootstrap, exchanged for access JWT. |
| Service account | client_id plus private key JWT or secret | service_account row | client_credentials and token exchange actor auth. |
| Delegated token | ES256 JWT with act claim | Short-lived bearer | Service 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 asreaktorAuth.exposedAdapter.AuthRuntimeConfig, qualified asreaktorAuth.config.
Everything else is graph composition.
| Node | Provides | Depends on |
|---|---|---|
AuthAuditNode | AuthAuditSink | ExposedAdapter (writes the append-only auth_audit_event trail) |
AuthRepositoryNode | AuthAppCatalog, AuthPrincipalDirectory | ExposedAdapter |
AuthJwtNode | JwtMinter, JwtVerifier, AuthExternalIdentityVerifier, signing keys | AuthRuntimeConfig |
AuthPersonalTokenNode | AuthPersonalTokens | JwtMinter, database-backed token service |
AuthSessionLifecycleNode | AuthSessionLifecycle | database-backed session service |
AuthServiceAccountNode | AuthServiceAccounts | JwtMinter, database-backed service accounts |
LoginInteractorNode | AuthLogin | app catalog, principal directory, external verifier, session lifecycle, minter |
AuthPatNode | AuthPat | personal token port |
AuthTokenGrantNode | AuthTokenGrants | PAT, service accounts, verifier, minter |
AuthSessionNode | AuthSessions | session lifecycle, principal directory, verifier, minter |
AuthHttpServiceNode | AuthHttpService | login, PAT, token grants, sessions |
AuthAppServiceNode | AuthAppService | app catalog |
ServiceNode projections | route handlers | AuthService, 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.
| Route | Capability | Behavior |
|---|---|---|
POST /auth/sign-in | AuthLogin | Verifies external provider token, resolves principal/membership, creates session, returns access + refresh. |
POST /auth/anonymous | AuthLogin.anonymous | Creates 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=SUPABASE | Designed AuthLogin extension | Exchanges 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/start | Designed email facade | Starts 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/verify | Designed email facade | Verifies Supabase email OTP or token hash, resolves/links the provider account, and returns Reaktor tokens. |
POST /auth/email/password/sign-in | Designed password facade | Authenticates via Supabase email/password behind Reaktor app policy, then returns Reaktor tokens. |
POST /auth/email/password/reset/start | Designed password facade | Starts Supabase password reset with Reaktor redirect policy and audit. |
POST /auth/email/password/update | Designed password facade | Completes password update from an authenticated reset/update flow. |
POST /auth/anonymous/link | Designed link facade | Verifies 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/token | AuthTokenGrants | Supports pat, personal_access_token, client_credentials, and RFC 8693 token exchange. |
POST /auth/pat/mint | AuthPat | Mints a PAT when authorized by bootstrap token or existing PAT with auth:pat:mint. |
POST /auth/pat/verify | AuthPat | Verifies raw PAT and returns token metadata. |
POST /auth/session/refresh | AuthSessions | Rotates refresh token; replayed old token revokes the family. |
POST /auth/session/logout | AuthSessions | Revokes the refresh-token family for the presented refresh token. |
POST /auth/session/me | AuthSessions | Introspects verified bearer claims into principal, scopes, roles, permissions, and actor. |
POST /auth/session/logout-all | AuthSessions | Revokes all sessions for the bearer principal. |
GET /.well-known/jwks.json | JwksController | Publishes public ES256 keys. |
GET /app, GET /app/{id} | AuthAppService | App 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
UserAuthenticationProviderbeans. - Reads
reaktor.jwt.ec-jwkandreaktor.jwt.ec-jwk-prev. - Creates
AuthRuntimeGraphwithSpringDependencyAdapter. - Exposes
AuthServer,AppServer,ReaktorSecurityWebFilter, andJwksController.
DefaultSecurityConfig:
- Installs
ReaktorSecurityWebFilteratAUTHENTICATION. - 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
AuthenticationwithSCOPE_,ROLE_, andPERMISSION_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-Userpath is gated byALLOW_USER_HEADER_AUTH=trueand is not the production auth path.
Service-to-Worker calls support two Reaktor-issued shapes:
- Reaktor service JWT with
principal_type=service, audience-bound and scoped withworker:server. - 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
| Layer | Owns |
|---|---|
| Supabase Auth | Email/password verification, email confirmation, password reset, magic-link and OTP delivery, provider-side session proof, email templates, SMTP integration, provider-side throttles. |
| Reaktor auth | App/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.SUPABASEexists in the common auth kernel.provider_accountis unique by(provider, issuer, subject)and can store Supabaseauth.users.idas the subject, the project auth URL as issuer, and the verified email metadata.auth_provider_clientcan 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
JwtVerifierwith 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 family | Shape | When to use |
|---|---|---|
| Client-first exchange | Native, 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 facade | Client 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.
Magic Link
Supabase magic links are passwordless login proof. Reaktor should use them in one of two ways:
- 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. - 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=falsefor sign-in flows.shouldCreateUser=trueonly 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.
Redirects, Deep Links, and Browser BFF
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.Supabaseaccepts a real Supabase JWT and rejects wrong issuer, wrong audience, expired token, tampered signature, missingsub, and unexpected algorithm.POST /auth/sign-in provider=SUPABASEreturns Reaktor access + refresh and persistsprovider_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 area | Examples |
|---|---|
| Availability | Disabled, 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 authority | Read-only public content, demo workspace read/write, ephemeral local state, scoped server writes, or linkable pre-signup state. |
| Demo data | Seeded tenant/context, sample project, public catalog, synthetic conversation, tutorial graph, or no seed for pure public browsing. |
| Upgrade behavior | Discard anonymous state, link the guest principal to a provider identity, copy selected guest state, or force sign-in before protected actions. |
| Limits | TTL, 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.ANONYMOUSalready exists in the kernel.- The schema already allows
principal.identity_idto 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. IntroducingPrincipalKind.ANONYMOUSimmediately 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:
AnonymousAuthRequestis part of the common service API.AuthLogin.anonymousis a first-class capability method.AuthHttpServiceNodeexposesPOST /auth/anonymous.- Spring permits
/auth/anonymousas a credential-issuing public route. LoginInteractorNode.anonymousvalidates app id, resolves the guest principal, checks principal and membership status, creates a session, mintscredential_type=anonymous, returns the normal token set, and emits audit.AuthRuntimeConfigcarries default anonymous roles, scopes, and permissions.- Refresh preserves anonymous tokens by inferring a
USERprincipal withidentity_id=nulland re-mintingcredential_type=anonymous. - Integration tests assert the anonymous handler exists and basic server calls succeed.
Still open:
- 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.
- 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.
- Add
AuthAdapter.loginAnonymous(appId, environment, flowId?)for clients. - Update Worker route policy so routes can intentionally allow or reject
credential_type=anonymouswhile still treating the principal as a user. - Implement anonymous-to-provider link/upgrade, including Supabase email upgrade.
Security rules:
- Rate-limit
POST /auth/anonymousper 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=anonymousintentionally 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/mereportsAuthMethod.ANONYMOUSandidentityId=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_idlinks all rotations in a session family.- Reuse of a used token revokes the family.
logoutrevokes one family.logout-allrevokes 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_atmetadata.
/auth/pat/mint requires either:
REAKTOR_PAT_BOOTSTRAP_TOKEN, or- an existing PAT whose scopes include
*,auth:*, orauth: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 againstservice_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=serviceandcredential_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.
| Task | Flows used | Result |
|---|---|---|
| Let a user try a Reaktor app before signup | Anonymous + refresh + optional link | First 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/password | Supabase password proof + Reaktor exchange | Supabase verifies the password; Reaktor verifies the provider token and returns Reaktor access + refresh. |
| User signs in with magic link or email OTP | Supabase passwordless start/verify + Reaktor exchange | Supabase delivers and verifies the email proof; Reaktor consumes it once and creates the app-scoped session. |
| Guest upgrades with Supabase email | Anonymous + Supabase proof + link | The existing guest principal gains a real identity without losing intentional guest state. |
| Native app signs in and calls a Worker | User flow | Provider credential becomes a Reaktor access token; Worker verifies locally. |
| App stays signed in for weeks | User + refresh | Access token stays short-lived; refresh rotates one-time tokens. |
| Developer CLI or MCP calls Reaktor APIs | PAT + token exchange | Long-lived PAT is exchanged for a short-lived access token. |
| Worker calls another Worker as itself | Service account | Worker mints client_credentials JWT and sends it as bearer. |
| Worker calls another Worker for a user | User + service account + delegation | Downstream sees the user subject and the service actor. |
| Worker calls the graph gateway | Service account + graph gateway | Worker uses service JWT over the Workers VPC HTTP binding. |
| Server handles HTTP auth but keeps policy portable | Spring boundary + AuthRequirement | Spring authenticates; Reaktor auth authorizes. |
| Graph-aware module composes auth internally | Runtime graph + secured ports | Ports wire only when the current AuthContext satisfies requirements. |
| Operator investigates auth behavior | Audit flow | auth_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.
Use Supabase Magic Link or Email OTP
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.audis the BestBuds app id.scpcontainsworker: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.
- The user signs in and calls chat with a user access token.
- Chat authenticates as
bestbuds-worker-service. - Chat exchanges the user token for a delegated token.
- 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_FAILURErows withgrant_type=client_credentialsusually means a Worker secret mismatch or a disabled service account. TOKEN_EXCHANGErows show service-on-behalf-of-user activity. Inspectactor_principal_id,subject_principal_id,audience, andscopes.TOKEN_REVOKErows 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).AuthNodeforAuthContextProviderandAuthContextSink.AuthPolicyNodeforAuthPolicy.SecuredProviderPortandSecuredConsumerPortforAuthRequirement-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:
| Check | Result |
|---|---|
npm run test:auth:karate:smoke | Pass, 9/9 live smoke scenarios. |
KARATE_USE_SERVER_APPLICATION_DB=1 npm run test:auth:karate:live | Pass, 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/karate | Pass, 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/karate | Pass, 2/2 live Worker delegation scenarios using the production bestbuds-worker-service credential. |
curl -i https://data-probe.shibasispatnaik.workers.dev/_data/graph/health | Pass, HTTP 200 with ok: true, proving Worker -> service JWT -> reaktorServer graph gateway over Workers VPC. |
./gradlew :reaktor-auth:jvmTest --console=plain | Pass, 48 auth JVM tests including audit, refresh, revoke, PAT, service-account, and token-exchange coverage. |
npm run k6:reaktorServer:auth:smoke | Structurally 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_credentialsminting 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_RPSandK6_DURATION. - future spike/soak/SLO budgets.
21. Deployment Status
BestBuds production path currently uses:
reaktorServerdeployed to k3s.reaktor-authas 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_eventfor mint, exchange, refresh, revoke, and failure flows. Live verification on June 5, 2026 showed recent rows forTOKEN_MINT,TOKEN_EXCHANGE,TOKEN_REFRESH,TOKEN_REVOKE, andAUTH_FAILURE.
Known deployed Worker versions from the rollout:
| Worker | Version |
|---|---|
reaktorServer | k3s image tag 20260605-1739 |
chat-service | 42703fdb-ff53-43f9-87b9-4b8e657ae208 |
messaging-service | c43238c6-d7c6-4fe8-81dd-3dc49406acb7 |
social-service | 009da7cf-2a48-4f34-b416-0e4414ccd850 |
media-service | e0df6d09-998f-4bcc-a753-fa15ec462d58 |
sticker-service | 5f4a95c2-ef6a-4efc-8ac7-ca49d6e23d26 |
analytics-service | 2194831e-0310-4978-80e5-d696ae77343b |
data-probe | 699e84f2-7595-413c-90c8-029b255ef45b |
notification-service | f4198d6b-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;
AuthRequirementauthorizes. - 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/karateortests/k6. Put them under the target. - Do not reintroduce the old
users/user_rolemodel 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 case | Status |
|---|---|
| Native/mobile login via Google/Apple ID token | Implemented |
| Sessions: create / refresh-rotate / reuse-detect / logout / logout-all | Implemented |
| Access tokens: ES256/JWKS mint + verify, key rotation | Implemented |
| PAT mint / verify / exchange / revoke | Implemented (mint authority needs bounding) |
Service accounts: client_credentials, private-key-JWT / secret | Implemented |
| RFC 8693 delegation token shape | Implemented (authority-bounding + may_act open) |
| RBAC, multi-tenant / context, resource checks | Implemented in kernel |
| Audit trail | Implemented |
| Anonymous app trial, store-review, and public-content access | Core implemented; app bootstrap policy, route policy, first-launch UX, demo-flow limits, and upgrade remain open |
| Supabase email/password + magic link + OTP | Designed 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 merge | Supabase anonymous-upgrade and link semantics designed; implementation open |
| Status revocation on refresh / access-token denylist | Not implemented |
Per-app provider-client binding (auth_provider_client) | Not implemented (config-driven) |
| MFA / step-up / passkeys / rate-limiting | Not 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.
- Bound credential-issuance to the caller's authority (PAT mint and token exchange) — specifics in the internal security review.
- Enforce principal/membership status on refresh; make audience-pinning the default at the bearer filter.
- Add the graph-free
AuthRuntimefacade plus a guardrail test that drives auth without namingGraph. - Secure client token-at-rest with the platform keystore.
- Cleanup: delete
LoginInteractor, de-duplicateAuthProviderClient, resolve the kernel SPIs, and refresh the section 07 node table. - 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:
- Supabase provider adapter:
UserAuthenticationProvider.Supabase, issuer / audience / JWKS verification, provider-account resolution, andPOST /auth/sign-in provider=SUPABASE. - 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.
- Anonymous app policy and bootstrap: first-launch entry, app-configured guest/demo flows, per-flow limits, Worker route policy, and link/upgrade.
- Browser Authorization Code + PKCE.
- BFF
__Host-cookie model for web. - DPoP for sender-constrained public-client tokens.
- Persisted
may_actdelegation policy. - ReBAC object tuples for instance-level permissions.
- First-class session device/IP/user-agent metadata.
- Mobile auth panes backed by live auth event/session APIs instead of
fixtures. The desktop pane no longer uses fixtures: it reads
heimdalldirectly 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). - Provider authorization (§31): Google and Apple
OAuth authorization codes, the
provider_grantstore, server-side token exchange and refresh, a runtime reader forauth_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.
| Product | Identity / principal model | Multi-tenancy | Authorization | Agents / M2M / delegation | First-party login + MFA | Shape |
|---|---|---|---|---|---|---|
| Heimdall | identity + typed principal (user/service/agent/actor) — strong | app + tenant + context (two-axis, weak) | RBAC + scope | token-exchange / delegation — strong | none | self-hosted, DB-first broker + AS |
| Auth0 | user + linked identities | Organizations | RBAC + FGA (add-on) | M2M apps | yes (+ passkeys, MFA) | SaaS |
| Okta | user + factors | orgs / groups | groups + policies | service apps | yes, + SCIM/SAML | SaaS, enterprise |
| Keycloak | user + federation | realms | realm/client roles, groups | client service accounts | yes | open-source, self-host |
| Cognito | user pools | pools (≈ tenant) | groups (weak) | app clients | yes (basic) | AWS |
| Clerk | user + orgs | Organizations | org roles / permissions | limited | yes (passkey-first) | SaaS, strong DX |
| WorkOS | user + orgs | Organizations | RBAC + FGA | yes | via AuthKit, + SAML/SCIM | SaaS, enterprise-readiness |
| Stytch | member / org | Organizations (B2B) | RBAC | M2M | yes (passkeys/OTP) | SaaS, B2B |
| Ory | Kratos identities | namespaces | Keto = Zanzibar ReBAC | Hydra (OAuth AS) | Kratos flows | open-source, composable |
| SpiceDB / OpenFGA | — (authorization only) | namespaces | ReBAC only | — | — | authorization engine |
| Entra ID | identities | directory tenant ≠ RBAC scope tree | RBAC with scope hierarchy | service principals / managed identities | yes | cloud |
| Supabase Auth | users | none (row-level security) | RLS in the database | service-role key | yes (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
AuthRequirementalready carries aresourcereference andanyOf/allOfcomposition (RBAC plus attribute-style conditions); what is missing is a relationship backing for theresourceseam, and the graph supplies it. This is the same capability §24 tracks as "ReBAC object tuples." ServiceNodemakes authorization part of typed routing. Each request handler is already exposed as a port; attaching anAuthRequirementto the handler means every call is checked uniformly, declared once, on both client and server (today viaAuthRequirementInterceptor).SecuredProviderPort/SecuredConsumerPortare 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."ActorNodeplus AGENT/ACTOR principals plus token-exchange give first-class authorized agents. Run an actor node under a delegatedAuthContext(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
AuthContextvia ports: any node can consume the current caller, typed, without threading it through every call. - Aspirational: route guards (an
AuthRequirementon 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.
| Archetype | Principal kinds | Tenancy | Authorization need | What it stresses |
|---|---|---|---|---|
| Consumer social (the reference app) | USER, AGENT (bots), SERVICE (workers) | single app, flat | simple roles plus per-resource grants (per-chat / per-group) | resource-level authorization, agent principals, social login |
| Developer platform | USER (devs), SERVICE (CI), AGENT (tools), PATs | multi-app | PATs, service accounts, app-scoped admin | machine-to-machine, PAT lifecycle, multi-app isolation, delegation |
| Personal / single-owner app | USER (owner), AGENT (AI assistant) | personal, light sharing | owner-has-all plus per-workspace shared scope | delegation, scope-per-workspace, share links |
| Regulated B2B commerce | USER (org members), SERVICE (integrations), ACTOR (delegated ops) | multi-tenant organizations | org roles plus resource-level plus relationship-based plus immutable audit | everything at once — the forcing function |
| Internal back-office | USER (employees), SERVICE | departmental scopes | employee roles, department-scoped, internal SSO | internal RBAC, single sign-on |
| Third-party SaaS customers | all of the above | hard per-customer isolation | full IdP (passkeys/MFA), SAML/SCIM, organizations | the 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)
| Threat | What is defended | What is still open |
|---|---|---|
| Spoofing (faking identity) | JWT verification (signature + audience + expiry); typed principals | the 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-reuse | tokens 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. subject | retention and tamper-evidence (a hash chain) for financial use |
| Information disclosure | secrets stored as hashes or references, never raw | keep security-critical claims/PII in typed columns, not free-form JSON |
| Denial of service | per-role statement timeouts | no rate-limiting or account lockout primitive yet |
| Elevation of privilege | LocalAuthorizer capability/role checks; secured ports | tenant 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
identitymust 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
| Provider | Platform | SDK in use | Captured today | Authorization is reached by | Distance |
|---|---|---|---|---|---|
| Android | GetSignInWithGoogleOption (Credential Manager) | idToken | a separate AuthorizationClient call (Play Services Identity) returning serverAuthCode + granted scopes | new SDK surface | |
| iOS | GIDSignIn | idToken | addScopes(...), then user.serverAuthCode — which requires serverClientID | already configured, code never read | |
| Web | google.accounts.id (One Tap) | credential (ID token) | google.accounts.oauth2.initCodeClient | different GIS namespace, not yet bound | |
| Apple | iOS | ASAuthorizationAppleIDProvider | identityToken | ASAuthorizationAppleIDCredential.authorizationCode | same object already being read |
| Apple | Web | AppleID.auth | authorization.id_token | authorization.code | same object, field already declared |
| Apple | Android | none | — | web flow in a Custom Tab returning code to a deep link | AndroidAppleLogin 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 inauth_audit_eventbeside token mints.- No
AuthMethodaddition. A provider grant is not a way to authenticate to Reaktor. Keeping it out ofAuthMethodis 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 intoAuthRuntimeGraphnext toAuthTokenGrantNode.ProviderGrantInteractorperforms exchange and refresh through a per-provider strategy:GoogleTokenEndpoint, andAppleTokenEndpointwrapping the ES256 client-secret minter from §31.4.AuthProviderClientRepositoryfinally givesauth_provider_client(§31.3) a reader, andAuthentication.ktshould source its audiences from it rather than from static construction.accessToken()mirrorsAuthAdapter.sessionHeaders(): serve the cached token if it survives the 60-second skew, otherwise refresh, otherwise mark the grantEXPIREDand 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:
- A provider grant never authenticates to Reaktor. Holding a Google access token proves nothing about a Reaktor principal.
- 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. - Exchange is server-side only. The client sends a code; the client never sees the resulting tokens.
- PKCE on every browser and native code flow, with the
auth_provider_client.redirect_urisallowlist enforced on every exchange. - 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.
- Account deactivation must revoke at the provider, not merely mark rows — required by Apple (§31.4), courteous with Google.
- 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
- Carry
authorizationCodeonAuthProviderUserand read it where it is already free —ASAuthorizationAppleIDCredential.authorizationCodeand Apple webauthorization.code. No new SDK surface; unblocks Apple revocation compliance. provider_granttable,AuthProviderGrantsport,AuthProviderGrantNode,AuthProviderClientRepository, and the Google token-endpoint strategy.- Google scope-up across the three client surfaces: Android
AuthorizationClient, iOSaddScopes, webinitCodeClient. - Apple token endpoint with the ES256 client-secret minter and rotation, plus first-authorization name capture (§31.4).
POST /auth/provider/authorize, grant listing, revocation, and wiring revocation into account deactivation.- Proactive refresh via
TokenRefreshWorker, theEXPIREDtransition, and a re-consent signal to the client. AndroidAppleLoginvia Custom Tab plus deep link, closing the stub.- Reconcile the duplicated
AuthProviderClientDTO (§31.3) and moveAuthentication.ktaudiences onto the table.
Steps 1 and 2 are load-bearing; everything after is a strategy or a surface on top of them.
Related docs
- 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.