Starmap supports four authentication mechanisms:
- Email/Password — session-based via Devise
database_authenticatable(always available) - OIDC SSO — session-based via Devise + OmniAuth (optional, when Keycloak configured)
- API Bearer Token (User) — stateless via Warden strategy +
OidcTokenValidator(optional, requires OIDC) - API Bearer Token (ApiClient) — stateless via Warden strategy +
OidcTokenValidatorfor machine-to-machine (optional, requires OIDC)
All User-based mechanisms resolve to the same User record. ApiClient tokens resolve to an ApiClient record for machine identity.
flowchart TB
subgraph KC["Keycloak (OIDC, optional)"]
direction LR
AC["Authorization Code Flow <br/> (browser redirect)"]
AT["Access Token<br/>(Bearer header)"]
CT["Client Credentials Token<br/>(machine-to-machine)"]
end
subgraph Browser["Browser Authentication"]
direction TB
Omni["OmniAuth openid_connect<br/>(omniauth.rb)"]
Session["Devise SessionsController<br/>(session cookie)"]
Omni --> Session
end
subgraph API["API Authentication"]
direction TB
Bearer["Warden BearerTokenStrategy<br/>(lib/warden/)"]
Validator["OidcTokenValidator<br/>(JWT validation + identity resolution)"]
Bearer --> Validator
end
subgraph M2M["Machine-to-Machine Authentication"]
direction TB
ApiClientStrategy["Warden ApiClientStrategy<br/>(lib/warden/)"]
Validator2["OidcTokenValidator<br/>(JWT validation + identity resolution)"]
ApiClientStrategy --> Validator2
end
subgraph Local["Local Authentication"]
direction TB
DBAuth["Email/Password<br/>database_authenticatable<br/>(Devise built-in)"]
end
subgraph Result["Devise / Warden"]
Helpers["current_user · authenticate_user!<br/>user_signed_in?"]
ApiHelpers["current_api_client · api_client_authenticated?<br/>(McpController)"]
end
KC -->|"OIDC SSO"| Omni
KC -->|"API token (user)"| Bearer
KC -->|"Client credentials"| ApiClientStrategy
Session --> Helpers
Validator --> Helpers
Validator2 --> ApiHelpers
DBAuth --> Helpers
Always available — works without OIDC configuration.
Standard Devise database_authenticatable with session cookies:
- User enters email + password on
/users/sign_in - Devise validates credentials against
encrypted_passwordin database - Creates session, sets cookie
- Subsequent requests: Warden deserializes user from session (
:fetchevent)
Devise modules enabled (see app/models/user.rb):
database_authenticatable— email/password loginrecoverable— password reset via emailrememberable— "remember me" cookietrackable— sign-in count, timestamps, IP addressesvalidatable— email/password validationsregisterable— self-registration (only whenREGISTRATION_ENABLED=true)omniauthable— OIDC SSO (only whenOIDC_ENABLED=true)
Key files:
app/models/user.rb— Devise module configurationapp/controllers/sessions_controller.rb— sign-in/sign-out (with OIDC logout support)app/controllers/users/registrations_controller.rb— sign-up (when enabled)
Flow: Authorization Code Grant with OIDC
- User clicks "Sign in with SSO" → redirects to Keycloak
- Keycloak authenticates user → redirects back with authorization code
- OmniAuth exchanges code for ID token + access token
- Devise creates session, sets cookie
- Subsequent requests: Warden deserializes user from session (
:fetchevent)
Configuration: config/initializers/omniauth.rb — OmniAuth provider :openid_connect with OidcConfig values.
Key files:
config/initializers/auth.rb—OidcConfigmoduleconfig/initializers/omniauth.rb— OmniAuth provider setupapp/controllers/users/omniauth_callbacks_controller.rb— callback handlingapp/controllers/sessions_controller.rb— custom sign-in/sign-out
Flow: Stateless token validation via explicit Warden strategy call
- Client obtains OIDC access token from Keycloak (e.g., via OAuth2 client credentials or authorization code)
- Client sends
Authorization: Bearer <token>in request header - MCP controller's
try_bearer_authcallswarden.authenticate(:bearer_token, scope: :user) BearerTokenStrategyvalidates token viaOidcTokenValidatorOidcTokenValidatordecodes JWT, verifies signature (JWKS), checks claims, resolves identity- If identity is
User→success!(user)— Warden stores user in:userscope - If identity is
ApiClient→ strategy returns silently (type guard), controller falls through toapi_client_tokenstrategy
Design decisions:
- No session storage —
store?returnsfalsein strategy. API clients don't get session cookies. - No trackable updates —
devise.skip_trackablepreventssign_in_countincrement on every API request. - Explicit strategy calls — strategies are invoked by name, not through
default_strategies. This avoids a known WardenConfig#dupissue where per-scope strategy lists are lost during proxy initialization. - Type guard — each strategy checks the identity type (
UserorApiClient) and silently returns if the type doesn't match its scope, allowing the next strategy to try.
Key files:
lib/warden/bearer_token_strategy.rb— Warden strategy for User Bearer tokensapp/services/oidc_token_validator.rb— JWT validation (JWKS cache, claim verification, identity resolution)app/controllers/mcp_controller.rb— MCP endpoint withauthenticate_any!
OidcTokenValidator validates OIDC access tokens (JWTs) issued by Keycloak and resolves the identity:
- Extract
kidfrom JWT header - Look up signing key from JWKS (cached 1 hour in
Rails.cache) - Decode JWT with public key (RS256)
- Verify claims:
exp(not expired),iss(matches issuer),aud(matches client_id if present) - Identity resolution:
- If
emailclaim present → findUserby email (existing behavior) - If
emailabsent → findApiClientbyazpmatchingoidc_client_idwhereenabled = true
- If
JWKS URI is discovered from issuer/.well-known/openid-configuration and cached 24 hours.
Error hierarchy:
OidcTokenValidator::InvalidToken— generic validation failureOidcTokenValidator::TokenExpired—expclaim is in the pastOidcTokenValidator::InvalidIssuer—issdoesn't match
All OIDC configuration is centralized in OidcConfig module (config/initializers/auth.rb):
| Method | ENV Variable | Required |
|---|---|---|
OidcConfig.issuer |
OIDC_ISSUER |
yes |
OidcConfig.client_id |
OIDC_CLIENT_ID |
yes |
OidcConfig.client_secret |
OIDC_CLIENT_SECRET |
yes |
OidcConfig.redirect_uri |
OIDC_REDIRECT_URI |
no |
When OIDC_CLIENT_ID is set, OIDC_ISSUER and OIDC_CLIENT_SECRET are also required.
Constants OIDC_ENABLED and REGISTRATION_ENABLED are derived from these values.
Registered as :bearer_token in Warden (required from config/initializers/devise.rb). Called explicitly by the MCP controller via warden.authenticate(:bearer_token, scope: :user).
Strategy behavior:
valid?— returnstrueonly ifAuthorizationheader starts withBearerauthenticate!— validates token viaOidcTokenValidator, checks identity isUser, callssuccess!(user). If identity isApiClientor an incompatible type, returns silently (allowing the next strategy to try).store?— returnsfalse(no session serialization for API clients)- Sets
env["devise.skip_trackable"]to prevent DB writes on each request
POST /mcp — JSON-RPC endpoint for AI assistants (OpenCode, etc.) and machine clients.
Auth flow:
McpControllertries session auth first (user_signed_in?), then Bearer token via explicit Warden strategy calls- Bearer token authentication: calls
warden.authenticate(:bearer_token, scope: :user)first (User), thenwarden.authenticate(:api_client_token, scope: :api_client)(ApiClient) OidcTokenValidatorresolves identity — User or ApiClient — based on JWT claims (emailpresent → User,emailabsent → ApiClient byazp)current_identity(User or ApiClient) is passed to MCP tools viaserver_context[:current_identity]
ApiClient policy routing: McpBaseTool#authorize checks identity type — ApiClient routes to ApiClient::TeamPolicy, User routes to TeamPolicy.
OAuth discovery endpoints (for automated client configuration):
GET /.well-known/oauth-authorization-server(RFC 8414) — proxies Keycloak OIDC metadataGET /.well-known/oauth-protected-resource(RFC 9728) — MCP resource metadata
Flow: Client credentials grant → stateless token validation
- Admin creates
ApiClientrecord via rails console withoidc_client_id,permissions, andteam_ids - Client obtains access token from Keycloak via
client_credentialsgrant - Client sends
Authorization: Bearer <token>to MCP endpoint - Controller calls
warden.authenticate(:api_client_token, scope: :api_client)— runsApiClientStrategy OidcTokenValidatordecodes JWT — noemailclaim, resolvesApiClientbyazpclaimsuccess!(api_client)— Warden stores identity in:api_clientscope, available viawarden.user(:api_client)
Design decisions:
- Separate Warden scope (
:api_client) — no risk of mixing identity types with:userscope - Explicit strategy calls — strategies are called by name (
warden.authenticate(:api_client_token, ...)) rather than relying ondefault_strategiesconfig, avoiding a known WardenConfig#dupissue where per-scope strategy lists are lost during proxy initialization - No Devise mapping — ApiClient has no Devise modules, uses non-bang
warden.authenticate - Manual 401 — controller handles unauthenticated response directly (no Devise FailureApp)
- Policy namespace —
ApiClient::TeamPolicyetc. underapp/policies/api_client/
Configuration: strategies are registered via Warden::Strategies.add in lib/warden/ files (required from config/initializers/devise.rb). No config.warden scope config needed.
Key files:
app/models/api_client.rb— machine identity model with permissions and team scopinglib/warden/api_client_strategy.rb— Warden strategy for ApiClient Bearer tokensapp/controllers/concerns/api_client_authenticatable.rb— controller concern providingcurrent_api_clientapp/policies/api_client/— policy namespace for ApiClient authorization
The Warden Bearer strategy is designed to scale to additional API consumers:
- Mobile app: Use standard OAuth2 Authorization Code Flow with PKCE → Keycloak issues access token → Bearer strategy validates it. Same
authenticate_user!works for both browser and mobile. - Backend service (machine-to-machine): Now supported via
ApiClientidentity andclient_credentialsgrant. See API Client Authentication section above.
For new API namespaces, add before_action -> { request.format = :json } to the base controller so Devise returns 401 instead of redirect.