github.com/PlaceOS/auth.cr
master / published Aug 13, 2026 / repository
auth.cr
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 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.
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 | /auth/authorize | OAuth2 authorization endpoint (code flow) |
| POST | /auth/token | OAuth2 token endpoint (authorization_code, client_credentials, refresh_token) |
| POST | /auth/revoke | RFC 7009 token revocation |
| GET | /auth/userinfo | OIDC userinfo (Bearer-gated) |
| GET | /.well-known/openid-configuration | OIDC discovery |
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. |
| 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.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.
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
- 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.