Skip to content

Repository files navigation

auth.cr

CI

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.

What it does

  • Local password sign-in — POST /auth/signin (bcrypt verify).
  • OAuth2 client flows — /auth/oauth2, /auth/oauth2/callback — per-tenant provider configs loaded from the oauth_strat table via spider-gazelle/multi_auth (and its custom GenericOAuth2 provider).
  • SAML SP — /auth/saml, /auth/saml/callback — backed by spider-gazelle/multi_auth_saml reading the adfs_strat table.
  • OAuth2 / OIDC server — /auth/authorize, /auth/token, /auth/revoke, /auth/userinfo, /.well-known/openid-configuration. Backed by place-labs/authly. JWTs are RS256 with the legacy u: {n, e, p, r} claim block + aud=authority.domain so downstream PlaceOS services keep validating tokens issued before cutover.
  • API key auth — X-API-Key header (HMAC-SHA512 secret format {id}.{secret}).
  • Login event publisher — fires {user_id, provider} JSON on the placeos/auth/login Redis channel after every successful login.

What's intentionally out of scope

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.

Routes

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)

Microsoft Entra token exchange

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_strat on the request's authority has a single-tenant Entra token/authorize URL (login.microsoftonline.com/<tenant>/..., or a sovereign-cloud equivalent). common/organizations strats cannot anchor an exchange.
  • The token's aud is that strat's client_id, api://<client_id>, or api://<authority host>/<client_id>. The last form is the App ID URI that Office add-in SSO requires.
  • The token's iss is 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 own iss.
  • tid matches the tenant, the token is unexpired, and it is a delegated token (scp present, oid present). App-only tokens are refused.
  • The strat's ensure_matching restriction passes. It is checked against the token claims projected onto Graph /me names (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

MCP clients (Claude Code, Claude Desktop, VS Code, Cursor, ...) sign users in to a PlaceOS MCP server without any manual client setup:

  1. The MCP server (rest-api) answers 401 with its protected resource metadata, which lists this service as the authorization server.
  2. The client reads /.well-known/oauth-authorization-server.
  3. The client identifies itself:
    • Client ID metadata document: the client_id is an https:// 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 a dcr- prefixed client_id.
  4. The client runs the authorization code flow with PKCE and the RFC 8707 resource parameter. 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_code and refresh_token, and their scopes to the defaults (public, openid, profile, email, offline_access).
  • PKCE with S256 is 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_HOSTS restricts which hosts may act as clients.
  • Registrations are rate limited to 10 per hour per IP.
  • A resource must be a URL on the request's authority (the token aud), otherwise invalid_target.

Administrator-registered applications see the consent screen unless skip_authorization is set. Set it on first-party apps such as Backoffice.

Environment

Required in production

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).

Optional / tunable

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.

Run

Locally (against a running Postgres + Redis)

shards install
crystal run src/app.cr -- -b 0.0.0.0 -p 3000

Useful flags

./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)

Container

docker build -t placeos/auth .
docker run --rm -p 3000:3000 -e JWT_SECRET=... -e COOKIE_SESSION_SECRET=... placeos/auth

The HEALTHCHECK probes /auth/healthz.

Test

./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 file

Iterating on placeos-models migrations

The 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

Working on the codebase

  • Code style: crystal tool format && ./bin/ameba — both must be clean before committing. CI rejects either.
  • Plans + lessons live in tasks/todo.md and tasks/lessons.md (please update both as you go — lessons.md has caught at least five upstream library quirks already).
  • PLAN.md is the high-level phase map.

Migration from the Ruby service

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.

Cutover steps

  1. Ensure JWT_SECRET matches the Ruby deployment — downstream services validate by signature, so a key rotation breaks them.
  2. Roll out placeos-models with the oauth_tokens migration first (or use the auth-replacement branch).
  3. Set COOKIE_SESSION_SECRET to a real ≥32-byte value. Existing _coauth_session cookies will be invalid — users re-sign-in.
  4. Switch the auth service container image to this build. The route table is wire-compatible.
  5. Watch the placeos/auth/login Redis channel + /auth/healthz for regressions.

About

No description, website, or topics provided.

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages