01 Governing Principle
The important boundary is Reaktor-issued access tokens, not Google/Apple/Supabase tokens. External providers authenticate the person. Reaktor authenticates and authorizes the call inside the Reaktor ecosystem.
The philosophical API direction: the interface should be simple at the surface, expressive underneath. The application developer should express who can do what on which resource under which app/tenant/context — not manually parse JWTs, fetch users, check string scopes, and throw 401/403.
val createEvent = post<CreateEvent, EventCreated>( "/events", auth = requires(EventAction.Create) .on(EventResource.fromBody { it.eventGroupId }) ) { req -> val auth = req.auth events.create(auth, req.body) }
The biggest redesign decision: stop treating auth as “login returns User + token” and make it “all calls enter with an AuthContext, and every protected capability declares an AuthRequirement.” Everything else follows from that.
02 Target Architecture
Every target — Spring server, Cloudflare Worker, KMP app graph, actor mailbox, CLI tool — converges at steps 6–8. The transport-specific part (steps 1–5) only adapts credentials into the common kernel.
03 User-to-Service Auth
A human user signs in through an app-branded provider flow. Reaktor maps that external credential to a global identity and app membership, creates a session, and issues short-lived Reaktor access tokens that services verify.
Authorization: Bearer <reaktor-access-token>Why not use the provider token as the API token?
- Google’s
audclaim must equal the app’s OAuth client ID — it is a proof of login to that OAuth client, not a general-purpose API credential for every Reaktor service. - Google warns not to use email as the stable identifier; use
subinstead. - RFC 9700 recommends audience-restricting access tokens to specific resource servers and refresh-token rotation for public clients.
Reaktor Access Token Claims
{
"iss": "https://auth.reaktor.build",
"aud": "manna-api",
"sub": "principal_usr_123",
"jti": "tok_123",
"sid": "ses_123",
"principal_type": "user",
"identity_id": "idn_123",
"app_id": "app_manna",
"tenant_id": "tenant_wedding_456",
"context_id": "event_group_789",
"scp": ["event.read", "event.write"],
"perm_ver": 42,
"amr": ["google"],
"iat": 1710000000,
"exp": 1710000900
}
04 Service-to-Service Auth
Service-to-service auth must not be modeled as “a fake user.” It is a first-class service principal.
A. Pure Service Principal
“manna-worker is calling manna-api.” Uses client_credentials grant with service account.
B. Tool / Developer Credential
“CLI/MCP/tool calls API using a PAT, then exchanges it for a short-lived JWT.”
C. User-Delegated Service Call
“manna-worker calls on behalf of user U after U triggered a long-running job.”
A. Client Credentials Flow
POST /auth/token grant_type=client_credentials client_id=svc_manna_worker client_secret=... audience=manna-api scope=knowledge.ingest
Token issued with sub: "principal_svc_manna_worker", principal_type: "service". OAuth 2.0 RFC 6749 explicitly supports this grant type for non-user service access.
B. PAT Exchange Flow
PATs are exchange credentials, not direct bearer credentials. The tool keeps the rak_ token locally, exchanges it for a 5–15 minute JWT via POST /auth/token grant_type=pat, then workers verify the JWT locally without database hits.
C. On-Behalf-Of (Delegated Actor)
{
"sub": "principal_usr_123",
"act": {
"sub": "principal_svc_manna_worker",
"principal_type": "service"
},
"aud": "manna-api",
"scp": ["job.result.write"],
"job_id": "job_123"
}
Authorization distinguishes subject (whose resources are affected) from actor (which service is performing the action). This avoids the common bug where a background worker either has too much power as “system” or loses the user context entirely.
05 Global Identity, App-Branded Login
Each app has its own configured OAuth client / branding. Google’s consent screen shows the app name, logo, and homepage configured in Google Cloud Console. Apple’s prompt is tied to the app / service configuration. The user never sees “Reaktor.”
Database Model
The database should not say “identity belongs to app.” It should say:
- Provider client belongs to app
- Provider account belongs to global identity
- Membership belongs to app / tenant / context
── Example ── app app_manna "Manna" app_bestbuds "BestBuds" auth_provider_client app_manna GOOGLE ANDROID client_id=manna-android-client app_manna GOOGLE WEB client_id=manna-web-client app_bestbuds GOOGLE ANDROID client_id=bestbuds-android-client app_bestbuds APPLE IOS bundle_id=dev.shibasis.bestbuds identity idn_123 provider_account identity_id=idn_123 provider=GOOGLE subject=<google_sub> membership principal_usr_123 app_manna ACTIVE principal_usr_123 app_bestbuds ACTIVE
Login validation: appId + provider + platform → expected issuer + expected audience/clientId. Do not auto-link accounts only by email. Use provider subject as the primary key.
06 Supabase Email & Magic Link
Supabase is treated as a credential provider, not as the Reaktor authorization system. Spring calls Supabase server-side using the service role key. The Reaktor client does not need to understand Supabase sessions.
Email / Password
POST /auth/login/email → Spring calls Supabase Auth → validates email/password → maps supabase_user_id to ProviderAccount → issues Reaktor tokens.
Magic Link
POST /auth/magic-link/start → Supabase sends email. POST /auth/magic-link/confirm → Spring verifies with Supabase → maps user → issues Reaktor tokens.
Supabase admin methods require the secret key and must be called only from trusted servers. The Supabase JWT does not become the Reaktor API token.
07 Core Domain Model
The main correction from the old model: split identity, principal, and membership into distinct concepts.
| Concept | Definition | Key Fields |
|---|---|---|
Identity | Global human identity (one per person across all apps) | id, status, primaryEmail, timestamps |
ProviderAccount | One login method attached to an identity | identityId, provider, issuer, subject, email, providerTenant |
Principal | The entity that can be authorized (user, service, agent, actor) | id, kind, identityId?, status |
Membership | App/tenant/context-specific attachment | principalId, appId, tenantId?, contextId?, status, profile |
AuthProviderClient | App-branded OAuth configuration | appId, provider, platform, clientId, issuer, redirectUris |
Tenant | Organizational unit within an app | id, appId, name, status |
ResourceRef | Pointer to a specific protected resource | type, id, appId, tenantId, contextId |
ProviderAccount Unique Keys
| Provider | issuer | subject | providerTenant |
|---|---|---|---|
https://accounts.google.com | Google sub | — | |
| Apple | https://appleid.apple.com | Apple user identifier | Apple Team ID |
| Supabase | https://<project>.supabase.co/auth/v1 | Supabase user ID | Supabase project ref |
Principal Kinds
USER
Human user. Has an Identity. Created at first login.
SERVICE
Service account. No Identity. Has client credentials.
AGENT
AI agent or autonomous system. Future-ready.
ACTOR
Graph actor / message-processing entity. Future-ready.
08 Token Model
| Credential | Format | Lifetime | Purpose |
|---|---|---|---|
| External provider credential | Google ID token / Apple identity token / Supabase session | Login only | Proves identity to external provider |
| Reaktor access token | JWT (ES256/RS256, asymmetric JWKS) | 5–15 min | Used by APIs, workers, graph services |
| Refresh token | Opaque random, hashed server-side | Days/weeks | Rotates to get new access tokens. User sessions only. |
| PAT / service credential | rak_ prefix, hashed server-side | Long-lived | Exchange-only — used to obtain short-lived access tokens |
Asymmetric Signing
Prefer ES256 or RS256 with JWKS. Workers and services verify with public keys — no shared signing secret needed. HS256 should not be the default because every verifier would need the signing secret.
Refresh Token Rotation
refresh_token table: id session_id token_hash family_id // for reuse detection previous_token_id created_at, used_at, rotated_at, expires_at, revoked_at
Every refresh rotates: old token → new access + new refresh. Old token becomes invalid. Reuse of old token revokes entire family (RFC 9700).
PAT Binding
PATs become app/audience-bound: appId, tenantId, contextId, allowedAudiences, scopes. The current model has userId, name, tokenHash, scopes, nullable contextId — but no required appId or audience binding.
09 AuthContext & AuthRequirement
AuthContext is the single most important primitive. It is the answer to “who is calling, for what app/tenant/context, with what authority?” Available everywhere: request.auth, workerContext.auth, graph.auth, actorMessage.auth.
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<Scope>, val roles: Set<RoleRef>, val permissions: Set<PermissionRef>, val method: AuthMethod, val actor: PrincipalRef? = null, val delegation: Delegation? = null, val claims: JsonObject = JsonObject(emptyMap()) )
Context Shapes by Auth Type
| Scenario | principal.kind | identityId | sessionId | actor |
|---|---|---|---|---|
| User → service | USER | present | present | null |
| Service → service | SERVICE | null | null | null |
| Service on behalf of user | USER | present | null | SERVICE ref |
AuthRequirement DSL
The current SecuredPort model checks required string scopes. That becomes the beginner path, but the kernel supports composable, resource-aware requirements:
// Simple scope check auth = requires("event.write") // Resource-aware auth = requires(Event.Write) .on(EventResource.fromPath("eventId")) // Multi-actor auth = anyOf( requires(Event.Write).forUsers(), requires("event.import").forServices() ) // Tenant-bound auth = requires(Event.Write) .inTenant { path["eventGroupId"] } // Delegated actor auth = requires(Job.WriteResult) .allowDelegatedActor("manna-worker")
11 Current Implementation Audit
What Already Exists
| Area | Status | Details |
|---|---|---|
| Kernel types | Done | AuthContext, AuthRequirement, AuthDecision, Principal, Identity, ProviderAccount, Membership, AuthProviderClient, Tenant, ResourceRef, all enums |
| Kernel interfaces | Done | Authenticator, Authorizer, TokenVerifier, TokenIssuer, SessionManager, PermissionResolver, TenantResolver |
| LocalAuthorizer | Done | Full authorization algorithm with constraint validation, anyOf/allOf, scopes, permissions, roles, resources, delegation |
| DSL functions | Done | requires(), permits(), anyOf(), allOf(), resource(), fluent methods on AuthRequirement |
| Secured graph ports | Done | SecuredProviderPort, SecuredConsumerPort, providesSecured(), consumesSecured() — now using AuthRequirement |
| AuthNode | Done | Graph node with AuthSession state, provider port, DB rehydration, login/logout |
| Platform providers | Partial | Google (Android, iOS, Web), Apple (iOS, Web), Android Apple TODO |
| Auth service API | Done | login, token, mintPat, verifyPat, exchangePat + TokenRequest/TokenResponse with grant types |
| PAT system | Done | Secure random generation, hash-only storage, revocation, exchange to JWT |
| RBAC DTOs | Done | App, User, Role, Permission, Session, Context, RolePermission, UserRole, PersonalAccessToken |
| Spring server | Partial | AuthServer routes, LoginInteractor, TokenInteractor, JwtVerifier, JwtMinter, Exposed tables/repos |
| UI components | Done | GoogleLoginButton, AppleLoginButton, icons |
Critical Gaps
| Gap | Impact | Current State |
|---|---|---|
| DefaultSecurityConfig permits all | No auth boundary on Spring | authorize(anyExchange, permitAll) + CSRF disabled. Actual protection depends on handler-local checks. |
| Fake refresh tokens | No session lifecycle | LoginInteractor returns UUID.randomUUID().toString() as refresh token with no persistence, rotation, or revocation. |
| No real sessions | Cannot revoke access | No server-side session store. No /refresh or /logout endpoints. |
| No app-branded provider clients | Cannot multi-app | Login verifies against hardcoded audience, not per-app AuthProviderClient. |
| No identity/principal split | Flat user model | Old User table conflates identity, principal, and membership into one row. |
| PATs not audience-bound | Over-privileged tokens | PAT has userId, scopes, contextId but no required appId or allowedAudiences. |
| No JWKS endpoint | Workers can’t verify locally | No /.well-known/jwks.json. Workers would need the signing secret or a DB hit. |
| No worker middleware | Cloudflare unprotected | No WorkerJwtVerifier, no JWKS cache, no Hono middleware. |
| No Supabase provider | Email/magic link blocked | No server-side Supabase integration. |
| No service accounts | No S2S auth | No client_credentials grant. No ServiceAccountService. |
| Apple iOS issues | Crashes on 2nd login | Force-unwraps email (it.email!!), logs serialized user, doesn’t persist first-login name/email. |
12 Planned API Tree
New items are marked in green. The kernel types and interfaces are already implemented; the gaps are primarily in Spring middleware, worker middleware, Supabase provider, service accounts, and real session lifecycle.
13 JVM / Spring Target
Security Configuration
Replace the current permitAll default with proper route-aware auth. Use both Spring Resource Server (validates Reaktor JWT) and a Reaktor WebFlux filter (maps to AuthContext, calls Authorizer).
// Public routes /auth/login/* /auth/magic-link/* /.well-known/jwks.json // Protected (everything else) authorize(anyExchange, authenticated)
Handler-Level Auth (Reaktor-Native)
val createEvent = PostHandler<CreateEvent, EventCreated>( route = "/events", auth = requires(Event.Write) .on(EventResource.fromBody { it.eventGroupId }) ) { req -> eventService.create(req.auth, req.body) } // Spring router wraps automatically: // extract bearer → authenticate → attach AuthContext // → evaluate handler.auth → call handler
Spring Service Routes
| Path | Method | Purpose |
|---|---|---|
/auth/login/google | POST | Google login |
/auth/login/apple | POST | Apple login |
/auth/login/email | POST | Supabase email/password |
/auth/magic-link/start | POST | Send magic link |
/auth/magic-link/confirm | POST | Confirm magic link |
/auth/session/refresh | POST | Rotate refresh token |
/auth/session/logout | POST | Revoke current session |
/auth/session/logout-all | POST | Revoke all sessions |
/auth/session/me | GET | Current user/session info |
/auth/session/sessions | GET | List active sessions |
/auth/token | POST | Token exchange (client_credentials / pat / on_behalf_of) |
/auth/token/introspect | POST | Token introspection |
/auth/token/revoke | POST | Token revocation |
/.well-known/jwks.json | GET | Public signing keys |
Required Stores
IdentityStore, ProviderAccountStore, PrincipalStore, MembershipStore, SessionStore, RefreshTokenStore, ServiceAccountStore, PatStore, GrantStore, SigningKeyStore, AuditStore. The existing repository/RBAC work can be preserved; the current User table should become principal + membership or a compatibility view.
14 Cloudflare Workers Target
Primary requirement: no DB hit per request, no shared signing secret, fast local verification.
Authorization: Bearercrypto.subtleval auth = reaktorWorkerAuth { issuer = "https://auth.reaktor.build" audience = "manna-mcp" jwksUrl = "https://auth.reaktor.build/.well-known/jwks.json" cache = WorkerJwksCache(kv = env.AUTH_KV) } app.use("*", auth.middleware()) app.post("/mcp", requires("mcp.read").forUsersOrServices()) { c -> val auth = c.auth mcp.handle(auth, c.req) }
Hono’s JWT middleware follows the same shape. Cloudflare Workers expose Web Crypto via crypto.subtle, which is the right primitive for JWT signature verification.
15 Android / iOS / Web Clients
App-Facing API
Feature.Auth.configure { appId = "app_manna" authBaseUrl = "https://auth.reaktor.build" providers { google() apple() supabaseEmail() supabaseMagicLink() } } Feature.Auth.login(Google) Feature.Auth.login(EmailPassword(email, password)) Feature.Auth.sendMagicLink(email) Feature.Auth.confirmMagicLink(tokenHash) Feature.Auth.currentSession.collect { session -> /* update UI */ }
Client Token Behavior
- Store refresh token securely (Keychain / EncryptedSharedPreferences / HttpOnly cookie)
- Keep access token in memory when possible
- Attach access token to Reaktor service calls via
AuthHttpPlugin - Refresh before expiry
- On refresh failure, clear session and emit logged-out state
Storage Split
| Store | Contents | Mobile | Web |
|---|---|---|---|
AuthSessionStore | User/session metadata | ObjectStore | Memory / localStorage |
SecureTokenStore | Refresh token / credential | Keychain / EncryptedPrefs | HttpOnly Secure cookie |
AccessTokenCache | Current access token | Memory | Memory |
Apple iOS Fixes Required
- Do not force-unwrap email (
it.email!!) — Apple only sends name/email on first authorization - Persist name/email received only on first login as profile hints
- Assign successful
AppleUserinto theMutableStateFlow - Remove token logging
16 Graph & Actors
This is where Reaktor can be better than Clerk/Auth0/Firebase. Auth travels with causality through the graph.
Graph Auth
class AuthNode(graph: Graph) : BasicNode(graph) { val authState: StateFlow<AuthContext?> val authPort by provides<AuthContextProvider>(...) } // Secured port with requirement val eventRepository by consumesSecured<EventRepository>( requires(Event.Read) .on(EventResource.fromGraphContext()) )
Actor Messages
Actor messages carry auth explicitly so authorization context is never lost across async boundaries:
data class AuthEnvelope<T>( val auth: AuthContext, val message: T, val causationId: String?, val correlationId: String? ) actor<EventCommand>( auth = anyOf( requires(Event.Write).forUsers(), requires("event.import").forServices() ) )
If a user action triggers a service job which sends an actor message, the actor sees both the subject and the service actor.
17 Tools, CLI & MCP
Tools use PATs or service accounts, then exchange for scoped JWTs.
$ reaktor auth login $ reaktor auth mint-pat --app manna --audience manna-mcp --scope mcp.read $ reaktor auth token --audience manna-mcp
Service Account Examples
manna-ingestion-worker— knowledge base ingest pipelinemanna-notification-worker— push notificationsbestbuds-chat-worker— message processingreaktor-deploy-agent— CI/CD automation
18 Migration Roadmap
Step 1: Kernel types — already done
AuthContext, AuthRequirement, Authenticator, Authorizer, TokenVerifier, TokenIssuer, SessionManager, LocalAuthorizer, DSL functions. Keep current LoginResponse but enrich with TokenSet.
Step 2: Real sessions & refresh tokens
Replace UUID.randomUUID() refresh token with persisted, hashed, rotating refresh tokens. Add POST /auth/session/refresh, POST /auth/session/logout, GET /auth/session/me.
Step 3: Spring middleware
Replace permitAll with route-aware auth. Add ReaktorSecurityWebFilter, ReaktorReactiveJwtDecoder. Public routes: /auth/login/*, /auth/magic-link/*, /.well-known/jwks.json.
Step 4: App-branded provider clients
Add auth_provider_client table. Update login verification: appId + provider + platform → expected clientId/audience.
Step 5: Identity / principal / membership split
Keep compatibility mapping: old User → new Principal + Membership. Migrate data gradually.
Step 6: Service accounts & PAT audience binding
Add ServiceAccountService, TokenService grant_type=client_credentials, PAT allowedAudiences + appId.
Step 7: Worker middleware
JWKS endpoint on Spring. Worker JWKS cache (KV). WorkerJwtVerifier with crypto.subtle. Hono/Reaktor middleware.
Step 8: Supabase provider
POST /auth/login/email, POST /auth/magic-link/start, POST /auth/magic-link/confirm. Spring calls Supabase server-side. Reaktor issues its own tokens after Supabase verification.
Mental Model
Identity
Who is this human globally?
Principal
What authenticated entity is acting? (user / service / agent / actor)
Membership
Where does this principal belong? (app / tenant / context)
Credential
How did they prove identity? (Google / Apple / Supabase / secret / PAT)
Session
Is this login still alive?
Token
What short-lived proof is presented to this service?
Requirement
What does this route/port/actor need?
AuthContext
The answer to “who is calling, for what app/tenant/context, with what authority?”
AuthContext and declare AuthRequirement. The transport varies. The authorization model does not.