Living plan. Update as routes land. See
tasks/todo.mdfor granular tracking,tasks/lessons.mdfor corrections captured along the way.
Replace /home/steve/projects/placeos/auth-replacement/auth (Ruby/Rails + Doorkeeper + OmniAuth) with a Crystal/spider-gazelle service that exposes the same wire protocol so existing PlaceOS clients keep working without changes.
| Concern | Decision |
|---|---|
Local password login (POST /auth/signin) |
Keep — backed by User#authenticate (bcrypt) |
| OAuth2 client (Google, Microsoft, generic) | Keep — implement via multi_auth, configured from oauth_strat rows |
| SAML / ADFS | Keep — implement via multi_auth_saml shard (spider-gazelle/multi_auth_saml), configured from adfs_strat rows |
POST /auth/signup |
Drop — Ruby route exists but is unused in production; auto-create OAuth users inline in Sessions#callback instead |
API key auth (X-API-Key) |
Keep — format {id}.{secret}, HMAC-SHA512 |
| OAuth2 / OIDC server (issue tokens) | Keep — implement via authly |
OAuth2 password grant |
Drop — return unsupported_grant_type |
| LDAP | Drop — no controller, no strategy, ignore ldap_strat table |
OmniAuth :developer strategy |
Drop — Rails-dev convenience only |
Redis login event publish (placeos/auth/login) |
Keep — pluggable post-login hook |
before_signup / after_login extension points |
Keep — Crystal-native equivalents |
┌─────────────────────────────┐
HTTP request (host) ──▶│ ApplicationController │
│ - resolve_authority (host) │
│ - parse session cookie / │
│ Bearer / X-API-Key │
└────────────┬────────────────┘
│
┌───────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
Sessions (cookie) multi_auth (OAuth/SAML) authly (OAuth server)
/auth/login /auth/:provider /auth/authorize
/auth/signin /auth/:provider/callback /auth/token
/auth/logout /auth/signup /auth/revoke
/auth/authority /auth/failure /auth/userinfo
/.well-known/*
- Data layer:
placeos-modelsshard. ReuseUser,Authority,UserAuthLookup(table:authentication),OAuthAuthentication(table:oauth_strat),SamlAuthentication(table:adfs_strat),ApiKey,DoorkeeperApplication(table:oauth_applications). Any missing tables (e.g. token store, openid request) are added on a newmodelsbranch. - Session cookie:
_coauth_session, path/auth, encrypted, payload{id, expires, iat}— preserved for compatibility with existing nginx/asset SSO. - JWT: RS256, issuer
"POS", audience =authority.domain, claims includeu: {n, e, p, r}. Same shape as Ruby Doorkeeper output so downstream services keep validating tokens. - Multi-tenancy: every request resolves
Authorityfromrequest.host(case-insensitive). All queries scoped onauthority_id.
| Method | Path | Ruby handler | Crystal controller#action | Notes |
|---|---|---|---|---|
| GET | /auth/login |
SessionsController#new |
Sessions#new |
Validates continue, dispatches to provider, supports API-key inline |
| POST | /auth/signin |
SessionsController#signin |
Sessions#signin |
Local bcrypt login |
| GET | /auth/logout |
SessionsController#destroy |
Sessions#destroy |
Updates logged_out_at, revokes token |
| GET/POST | /auth/:provider/callback |
SessionsController#create (OmniAuth) |
Sessions#callback |
Bridges multi_auth.user(query_params) / multi_auth_saml |
| GET/POST | /auth/:provider/callback/:strategy |
same | Sessions#callback |
Path-style strategy id (legacy alias) |
/auth/signup |
SignupsController#create |
(dropped) | Unused — OAuth users auto-created inline in Sessions#callback |
|
| GET | /auth/failure |
SignupsController#show |
Failures#show |
Failure page |
| GET | /auth/authority |
AuthoritiesController#current |
Authorities#current |
Authority info + token check + health |
| POST | /auth/token |
Doorkeeper | authly handler |
Drop password grant |
| GET | /auth/authorize |
Doorkeeper | authly handler |
|
| POST | /auth/revoke |
Doorkeeper | authly handler |
|
| GET | /auth/userinfo |
Doorkeeper OIDC | authly handler |
|
| GET | /.well-known/openid-configuration |
Doorkeeper OIDC | authly handler |
|
| GET | /.well-known/oauth-authorization-server |
Doorkeeper OIDC | authly handler |
|
| GET | /.well-known/webfinger |
Doorkeeper OIDC | authly handler (or stub if unused) |
Verify in scope |
Each phase: spec first (reproduce expected behaviour or describe new contract), implement, format/lint, run ./test via subagent, mark done.
- Cut a new branch (
auth-replacement) on../models; pinauth.crto it from day 1. - Add deps to
shard.yml:placeos-models(branch),multi_auth,multi_auth_saml,authly,pg-orm,jwt,secrets-env,redis. - Mirror
rest-apilayout:src/controllers/application.cr,src/constants.cr,src/config.cr,src/app.cr. - Spec scaffolding:
spec/helper.cr,spec/spec_helpers/{authentication,client,spec}.crported fromrest-api. No Elasticsearch in the test stack — auth flows use direct DB lookups. ./testscript +docker-compose.ymlwith Postgres only.- CI workflow mirroring
rest-api(crystal-style,containerised-test).
ApplicationController: authority resolution by host, JWT/cookie/API-key parsing, error handlers (Unauthorized, Forbidden, RecordNotFound, RecordInvalid).- Cookie session helpers (encrypt/decrypt
_coauth_session, set/clear, redirect safety). Authorities#current(GET/auth/authority) + spec — simplest endpoint, validates scaffolding end-to-end.
Sessions#signin(POST/auth/signin) + spec — bcrypt verify, set session, optional 202 / redirect.Sessions#destroy(GET/auth/logout) + spec.Sessions#new(GET/auth/login) + spec —continuevalidation, API-key inline flow, provider redirect.Failures#show(GET/auth/failure) + spec.
- Mount
authlyhandler under/auth/.... ImplementAuthlyOwner,AuthlyClient,AuthlyClaimsProvider,AuthlyTokenStoreagainst placeos-models. - JWT signer compatible with existing Ruby tokens (RS256, issuer "POS", claim shape
{iss,iat,exp,jti,aud,scope,sub,u}). - Spec coverage for: authorization_code, client_credentials, refresh_token, revoke, userinfo,
.well-known/openid-configuration. - Verify
passwordgrant returnsunsupported_grant_type.
- Wire
multi_authprovider registration fromoauth_stratrows at request time (per-Authority dynamic config). Sessions#callback(GET/POST/auth/:provider/callback) — handle three Ruby scenarios:- signed-in user adding a new provider link,
- new user + new auth (with
before_signup_blockhook), - existing auth + existing user.
- Path-style alias
/auth/:provider/callback/:strategy(matches RewriteCallbackRequest middleware semantics). - Azure B2C redirect rewrite (matches RewriteRedirectResponse).
- Inline user auto-creation in the callback when no
UserAuthLookupexists (replaces the dropped/auth/signupflow).
- Wire
multi_auth_samlprovider registration fromadfs_stratrows at request time. - Route SAML callbacks through the same
Sessions#callbackshape (/auth/:provider/callback) so the user-mapping branches stay shared with OAuth.
- Redis login event publisher (
placeos/auth/login). Authentication.before_signup/after_loginCrystal equivalents (callable hooks, not Ruby blocks).- API key validation (
X-API-Keyheader) — already partly in Phase 1, finalise.
- Compatibility audit against the Ruby service: identical cookie format, identical JWT shape, identical response codes/headers for each endpoint.
- Deployment: Dockerfile mirroring
rest-api, env var documentation. - Update changelog / README.
oauth_access_grants/oauth_access_tokenstables — migrations exist inmodelsbut no Crystal model. Authly's own token store will replace them; decide whether to migrate existing tokens or invalidate on cutover.- Token signing key compatibility — confirm
JWT_SECRETenv var format is reusable as-is, so existing public keys still verify issued tokens. /.well-known/webfinger— actually used by any consumer? If not, drop.
SAML library— usingspider-gazelle/multi_auth_saml.Elasticsearch in test stack— not needed; auth flows are direct DB lookups.— dropped; OAuth users are created inline in the callback.POST /auth/signup
- All routes in the table above respond with the same status codes and response shapes as the Ruby service.
./testis green (per-controller specs, integration where authly is involved).crystal tool formatclean;./bin/amebaclean.- README documents env vars, Docker run, and the dropped LDAP / password-grant behaviour.
- A staff engineer would approve the diff.