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 (code flow), 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.
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. - Registrations are rate limited to 10 per hour per IP.
- 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_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.