Crystal/spider-gazelle replacement for the legacy Ruby/Rails auth
service. Route-for-route compatible with the existing wire protocol so
clients can flip over without changes.
- Local password sign-in —
POST /auth/signin(bcrypt verify). - OAuth2 client flows —
/auth/oauth2,/auth/oauth2/callback— per-tenant provider configs loaded from theoauth_strattable viaspider-gazelle/multi_auth(and its customGenericOAuth2provider). - SAML SP —
/auth/saml,/auth/saml/callback— backed byspider-gazelle/multi_auth_samlreading theadfs_strattable. - OAuth2 / OIDC server —
/auth/authorize,/auth/token,/auth/revoke,/auth/userinfo,/.well-known/openid-configuration. Backed byplace-labs/authly. JWTs are RS256 with the legacyu: {n, e, p, r}claim block +aud=authority.domainso downstream PlaceOS services keep validating tokens issued before cutover. - API key auth —
X-API-Keyheader (HMAC-SHA512 secret format{id}.{secret}). - Login event publisher — fires
{user_id, provider}JSON on theplaceos/auth/loginRedis channel after every successful login.
| Dropped | Why |
|---|---|
OAuth2 password grant |
Deprecated by OAuth 2.1; security risk. Token endpoint returns unsupported_grant_type. Local logins still work via the cookie session on POST /auth/signin. |
| LDAP | All current tenants have migrated off. |
POST /auth/signup |
Unused in production. OAuth users are auto-created inline in the callback when no UserAuthLookup exists. |
OmniAuth :developer strategy |
Rails-dev convenience only. |
| Method | Path | Purpose |
|---|---|---|
GET |
/auth/healthz |
Liveness |
GET |
/auth/authority |
Authority info + session/token flags (?health= makes it a probe) |
POST |
/auth/signin |
Local password login |
GET |
/auth/logout |
Session teardown + Bearer revoke + logged_out_at stamp |
GET |
/auth/login |
Inline login dispatcher |
GET |
/auth/failure |
OAuth / SAML failure landing page |
GET |
/auth/oauth2 |
Kickoff OAuth2 client flow |
GET/POST |
/auth/oauth2/callback[/:strategy] |
Consume OAuth2 callback |
GET |
/auth/saml |
Kickoff SAML SP flow |
GET/POST |
/auth/saml/callback[/:strategy] |
Consume SAMLResponse |
GET/POST |
/auth/authorize |
OAuth2 authorization endpoint (response_type=code or token), consent screen for clients that require it |
POST |
/auth/register |
RFC 7591 dynamic client registration (public clients, e.g. MCP clients) |
POST |
/auth/token |
OAuth2 token endpoint (authorization_code, client_credentials, refresh_token, RFC 8693 token exchange) |
POST |
/auth/revoke |
RFC 7009 token revocation |
GET |
/auth/userinfo |
OIDC userinfo (Bearer-gated) |
GET |
/.well-known/openid-configuration |
OIDC discovery |
GET |
/.well-known/oauth-authorization-server |
RFC 8414 metadata (same document) |
Apps already signed in to Microsoft (the Outlook add-in, Intune-managed clients) can trade their Entra access token for a PlaceOS token pair (RFC 8693) without a second login:
POST /auth/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=<PlaceOS application uid>
&subject_token=<Entra access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=public (optional; defaults to public)
The response is the normal token response plus
issued_token_type: urn:ietf:params:oauth:token-type:access_token.
Failures return 400 invalid_grant. The reason is logged and not returned
to the client.
The Entra token is accepted when all of the following hold:
- An
oauth_straton the request's authority has a single-tenant Entra token/authorize URL (login.microsoftonline.com/<tenant>/..., or a sovereign-cloud equivalent).common/organizationsstrats cannot anchor an exchange. - The token's
audis that strat'sclient_id,api://<client_id>, orapi://<authority host>/<client_id>. The last form is the App ID URI that Office add-in SSO requires. - The token's
issis exactly the issuer published by that tenant's discovery document (v1 or v2), and it is RS256-signed by a key from that document's JWKS. Keys are never fetched from the token's owniss. tidmatches the tenant, the token is unexpired, and it is a delegated token (scppresent,oidpresent). App-only tokens are refused.- The strat's
ensure_matchingrestriction passes. It is checked against the token claims projected onto Graph/menames (id,mail,userPrincipalName,displayName, ...).
The user is resolved exactly as an SSO login through that strat would
resolve them (UserAuthLookup on oid, then email, then auto-create). If
the user has no unexpired Graph token, one is requested on their behalf
(OAuth 2.0 on-behalf-of) and stored on the user. This step is best-effort.
GET /auth/authorize?response_type=token (RFC 6749 §4.2)
redirects straight back to the client with the access token in the URL
fragment:
https://app.example/oauth-resp.html#access_token=…&token_type=Bearer&expires_in=7200&state=…
This is what ts-client sends unless an app sets auth_type: 'auth_code', and
what the Ruby service served. The token is the same user-bound JWT the code
flow issues, with the same 2 hour lifetime, and it is persisted so
/auth/token/info, /auth/introspect and /auth/revoke all apply to it. No
refresh token is issued; the client runs the flow again when the token
expires. Scope is validated at the authorization endpoint, as there is no
token endpoint step, and an omitted scope defaults to public. Self
registered clients may not use it.
MCP clients (Claude Code, Claude Desktop, VS Code, Cursor, ...) sign users in to a PlaceOS MCP server without any manual client setup:
- The MCP server (rest-api) answers
401with its protected resource metadata, which lists this service as the authorization server. - The client reads
/.well-known/oauth-authorization-server. - The client identifies itself:
- Client ID metadata document: the
client_idis anhttps://URL serving the client's metadata (MCP's preferred method). No registration and no database row; the document is fetched and cached for 5 minutes to 1 hour. - Dynamic registration:
POST /auth/register(RFC 7591) creates a public client with adcr-prefixedclient_id.
- Client ID metadata document: the
- The client runs the authorization code flow with PKCE and the
RFC 8707
resourceparameter. The user signs in as usual, then approves the client on a consent screen.
Safeguards for self registered clients:
- They are public clients only (no secret). Their grants are limited to
authorization_codeandrefresh_token, and their scopes to the defaults (public,openid,profile,email,offline_access). - PKCE with
S256is mandatory. - Every authorization shows the consent screen. The form is protected by an HMAC token bound to the session and the exact request, and the page can't be framed.
- Redirect URIs must be https, loopback http, or a private-use scheme.
Loopback redirects (
127.0.0.1,[::1],localhost) match on any port (RFC 8252 §7.3), for every client. - Metadata documents are only fetched over https from public hosts, without
following redirects, with short timeouts and a 10KB cap.
MCP_CLIENT_ID_HOSTSrestricts which hosts may act as clients. - A registration matching an existing one (same client name and redirect URIs)
returns the existing
client_id, so every user of a client such as Claude or ChatGPT shares one registration. Only new client identities count towardsMCP_REGISTRATION_LIMITper hour. This is a global limit, not per IP: hosted clients register from shared IP addresses. - A
resourcemust be a URL on the request's authority (the tokenaud), otherwiseinvalid_target.
Administrator-registered applications see the consent screen unless
skip_authorization is set. Set it on first-party apps such as Backoffice.
| Var | Purpose |
|---|---|
JWT_SECRET |
Base64-encoded RSA private PEM. Signs every issued JWT (RS256). Public key derived. Falls back to a hardcoded dev key if unset — DO NOT ship that to production. |
COOKIE_SESSION_SECRET |
≥32 bytes. Encrypts/signs the session cookie. Dev fallback generates an ephemeral key per boot. |
PG_DATABASE_URL or PG_HOST / PG_PORT / PG_DB / PG_USER / PG_PASSWORD |
PostgreSQL connection. |
REDIS_URL |
Used to publish login events. If unset, LoginEvents.publish is a no-op (logged at warn). |
| Var | Default | Purpose |
|---|---|---|
SG_ENV |
development |
production flips secure-cookie + production logging. |
JWT_ISSUER |
POS |
iss claim on issued JWTs. Match the legacy Ruby value or services that pin issuer will reject. |
SESSION_TIMEOUT_MINUTES |
1440 |
Session-cookie max age. Per-authority override available via authority.internals["session_timeout"]. |
LOGIN_EVENTS_CHANNEL |
placeos/auth/login |
Redis pub/sub channel for login events. |
MCP_REGISTRATION_LIMIT |
10 |
New dynamic client registrations allowed per hour (per process). Registrations matching an existing client are reused and don't count. |
MCP_CLIENT_ID_HOSTS |
(unset) | Comma separated hosts allowed to serve client ID metadata documents (e.g. claude.ai,vscode.dev). Unset allows any public https host. |
PLACE_URI |
(unset) | Base URL used when a legacy X-API-Key validation needs to round-trip to the core engine. |
shards install
crystal run src/app.cr -- -b 0.0.0.0 -p 3000./placeos-auth --routes # dump the route table
./placeos-auth --docs # OpenAPI YAML on stdout
./placeos-auth --env # list every ENV var the app touched
./placeos-auth --version
./placeos-auth -c http://127.0.0.1:3000/auth/healthz # health-check (exit 0 on 2xx-4xx)docker build -t placeos/auth .
docker run --rm -p 3000:3000 -e JWT_SECRET=... -e COOKIE_SESSION_SECRET=... placeos/authThe HEALTHCHECK probes /auth/healthz.
./test spins up Postgres + Redis + a migrator container (clones
placeos/models@<branch> and runs micrate up) and runs the spec
suite end-to-end.
./test # full suite
./test spec/controllers/oauth_spec.cr # one fileThe migrator's git clone is Docker-cached. After pushing new
migrations to the placeos/models branch this project points at:
docker compose build --no-cache migrator
./test- Code style:
crystal tool format && ./bin/ameba— both must be clean before committing. CI rejects either. - Plans + lessons live in
tasks/todo.mdandtasks/lessons.md(please update both as you go —lessons.mdhas caught at least five upstream library quirks already). PLAN.mdis the high-level phase map.
| Aspect | Ruby | auth.cr |
|---|---|---|
| Session cookie | _coauth_session at path /auth, Rails AES encryption |
Same name + path, but action-controller MessageEncryptor wire format. Users are forced to re-sign-in at cutover (acceptable for an auth-service rotation). |
JWT issuer (iss) |
POS |
POS |
| JWT shape | {iss, iat, exp, jti, aud, scope, sub, u:{n,e,p,r}} |
Same |
| Bcrypt password digest | Stored in user.password_digest |
Same (placeos-models reused). |
OmniAuth :generic_oauth |
DB-driven oauth_strat |
multi_auth factory under the oauth2 provider name; same oauth_strat rows. |
OmniAuth :generic_adfs |
DB-driven adfs_strat |
multi_auth_saml factory under the saml provider name; same adfs_strat rows. |
OAuth password grant |
Allowed | Rejected with unsupported_grant_type. |
POST /auth/signup |
Manual signup endpoint | Removed; OAuth users are auto-created inline in the callback. |
| Redis login channel | placeos/auth/login |
Same. |
| LDAP | Supported | Dropped. |
- Ensure
JWT_SECRETmatches the Ruby deployment — downstream services validate by signature, so a key rotation breaks them. - Roll out
placeos-modelswith theoauth_tokensmigration first (or use theauth-replacementbranch). - Set
COOKIE_SESSION_SECRETto a real ≥32-byte value. Existing_coauth_sessioncookies will be invalid — users re-sign-in. - Switch the auth service container image to this build. The route table is wire-compatible.
- Watch the
placeos/auth/loginRedis channel +/auth/healthzfor regressions.