Skip to content
PlaceOSPublic

About

No description, website, or topics provided.

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

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

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.

Implicit grant

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

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.
  • 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 towards MCP_REGISTRATION_LIMIT per hour. This is a global limit, not per IP: hosted clients register from shared IP addresses.
  • 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_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.

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