You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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).
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)
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.
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)
./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).