Passwordless authentication server and OpenID Connect (OIDC) Identity Provider.
Users sign in by clicking a time-limited magic link sent to their email — no passwords stored or transmitted. magic-auth issues RS256/ES256 signed JWTs and acts as a full OIDC IdP, so any application that supports OAuth2/OIDC can delegate authentication to it.
# docker-compose.yml
services:
magic-auth:
image: jlcox1970/magic-auth:latest
ports:
- "8080:8080"
environment:
# ── Required ──────────────────────────────────────────────────────────
ISSUER: "http://localhost:8080"
JWT_SECRET: "change-me-32-chars-minimum!!"
FINGERPRINT_SECRET: "change-me-32-chars-too!!"
# ── Signing key (generate below) ──────────────────────────────────────
JWK_PRIVATE_KEY: |
-----BEGIN RSA PRIVATE KEY-----
<paste your key here>
-----END RSA PRIVATE KEY-----
# ── Database ──────────────────────────────────────────────────────────
DB_DRIVER: "rqlite"
RQLITE_URL: "http://rqlite:4001"
# ── Email via NATS ────────────────────────────────────────────────────
NATS_URL: "nats://nats:4222"
FROM_ADDRESS: "[email protected]"
BASE_URL: "http://localhost:8080"
# ── Optional ──────────────────────────────────────────────────────────
LOG_LEVEL: "info"
PORT: "8080"
SECURE_COOKIES: "true" # set false for local HTTP dev
# LOGIN_UI_URL: "" # leave empty to use the built-in login UI
# CORS_ORIGINS: "http://localhost:3000"
# ALLOWED_REDIRECT_DOMAINS: "app.example.com"
depends_on:
- rqlite
- nats
rqlite:
image: rqlite/rqlite:8
ports:
- "4001:4001"
volumes:
- rqlite-data:/rqlite/file
command: ["-node-id", "1", "-http-addr", "0.0.0.0:4001", "-raft-addr", "0.0.0.0:4002"]
nats:
image: nats:2-alpine
ports:
- "4222:4222"
command: ["-js"]
volumes:
rqlite-data:
# RSA (RS256) — recommended
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
| openssl pkey -traditional > private.pem
export JWK_PRIVATE_KEY="$(cat private.pem)"
# EC (ES256) — smaller tokens, faster verification
openssl ecparam -name prime256v1 -genkey -noout \
| openssl pkey > ec-private.pem
export JWK_EC_PRIVATE_KEY="$(cat ec-private.pem)"
| Variable | Required | Default | Description |
|---|---|---|---|
ISSUER | ✅ | — | Public URL of this server. Included in JWT iss claim. |
JWT_SECRET | ✅ | — | HMAC secret for session cookies. Minimum 32 characters. |
FINGERPRINT_SECRET | ✅ | — | Secret for browser fingerprint hashing. Minimum 32 characters. |
JWK_PRIVATE_KEY | ✅* | — | PEM RSA private key for RS256 signing. |
JWK_EC_PRIVATE_KEY | ✅* | — | PEM EC P-256 key for ES256. Use instead of JWK_PRIVATE_KEY. |
DB_DRIVER | — | rqlite | rqlite or postgres |
RQLITE_URL | — | http://rqlite:4001 | rqlite HTTP endpoint |
POSTGRES_DSN | — | — | PostgreSQL DSN e.g. postgres://user:pass@host/db?sslmode=require |
NATS_URL | ✅ | — | e.g. nats://nats:4222 |
NATS_STREAM_NAME | — | EMAILS | JetStream stream name |
NATS_SUBJECT | — | emails.send | Subject to publish email messages on |
NATS_STREAM_MAX_BYTES | — | 134217728 | Stream storage cap in bytes (128 MiB) |
NATS_STREAM_MAX_AGE | — | 24h | Maximum age of retained messages |
FROM_ADDRESS | — | [email protected] | From address in magic link emails |
BASE_URL | — | http://localhost:8080 | Base URL used when constructing magic link URLs |
LOGIN_UI_URL | — | — | External login UI URL. If empty, built-in UI served at /login-ui. |
CORS_ORIGINS | — | — | Space-separated allowed CORS origins |
ALLOWED_REDIRECT_DOMAINS | — | — | Comma-separated domains permitted in OIDC redirect_uri. Empty = any domain. |
SECURE_COOKIES | — | true | Set false for local HTTP development. |
LOG_LEVEL | — | info | debug, info, warn, error |
PORT | — | 8080 | Listen port |
BCRYPT_COST | — | 12 | bcrypt work factor for token hashing |
RBAC_RULES | — | — | JSON array of bootstrap RBAC rules. Seeded to DB on first startup if no DB rules exist. See Role System below. |
*One of JWK_PRIVATE_KEY or JWK_EC_PRIVATE_KEY is required.
The database schema is managed automatically — migrations run on startup, no manual steps required.
| Endpoint | Description |
|---|---|
GET /.well-known/openid-configuration | Discovery document |
GET /.well-known/jwks.json | Public key set |
POST /oauth/register | Dynamic client registration (RFC 7591) |
GET /oauth/authorize | Start the authorization code flow |
POST /oauth/token | Exchange code for tokens / rotate refresh token |
GET /oauth/userinfo | Returns claims for the token holder |
POST /oauth/revoke | Token revocation (RFC 7009) — also clears SSO session |
# Confidential client (server-side app)
curl -X POST http://localhost:8080/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-app",
"redirect_uris": ["https://my-app.example.com/auth/callback"]
}'
# Public client (SPA / mobile — uses PKCE, no secret)
curl -X POST http://localhost:8080/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-spa",
"redirect_uris": ["https://my-spa.example.com/callback"],
"token_endpoint_auth_method": "none"
}'
Store the client_secret — it is shown only once.
{
"sub": "f2af861cfa40707e1b89ea2713b1d481",
"email": "[email protected]",
"name": "user",
"preferred_username": "user",
"picture": "https://www.gravatar.com/avatar/<md5>?d=identicon&s=200",
"roles": ["user"],
"iss": "https://oidc.example.com",
"aud": ["your-client-id"],
"exp": 1744000000,
"iat": 1743996400
}
For simple integrations that do not need full OIDC:
# 1. Request a magic link — email is sent via NATS
curl -X POST http://localhost:8080/api/auth/request \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
# 2. User clicks the link in their email
# magic-auth sets session cookies and returns the authenticated user
# 3. Check the current session
curl http://localhost:8080/api/auth/me \
-H "Authorization: Bearer <access_token>"
# 4. Logout
curl -X POST http://localhost:8080/api/auth/logout
Every user has one or more roles embedded in their JWT at issuance time.
| Role | Description |
|---|---|
global_admin | Full access to all users, clients, and server configuration |
app_admin | Scoped to their own client — manages users who have logged into their app |
user | Self-service only — can read their own profile |
Roles are resolved at token issuance from (in priority order): explicit per-user override → RBAC email rule → RBAC domain rule → default ["user"]. Custom roles can be defined per client and set as the default for first-time logins.
On a fresh install there are no admins yet. Use the RBAC_RULES environment variable to seed the first admin on startup:
environment:
RBAC_RULES: '[{"client_id":"*","principal":"[email protected]","principal_type":"email","roles":["global_admin"]}]'
On first boot, if no DB rules exist yet, these rules are written to the database automatically. Once any DB rule exists the seed is skipped — the DB always takes precedence. You can leave RBAC_RULES set permanently as a live fallback, or remove it after confirming the DB rule exists.
Domain rules work the same way — to make everyone at a domain a global_admin on first deploy:
RBAC_RULES: '[{"client_id":"*","principal":"example.com","principal_type":"domain","roles":["global_admin"]}]'
# Assign global_admin by email across all clients
curl -X POST http://localhost:8080/oauth/rbac/rules \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{
"client_id": "*",
"principal": "[email protected]",
"principal_type": "email",
"roles": ["global_admin"]
}'
# Assign app_admin to everyone at a domain for a specific client
curl -X POST http://localhost:8080/oauth/rbac/rules \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{
"client_id": "<client_id>",
"principal": "example.com",
"principal_type": "domain",
"roles": ["app_admin"]
}'
Once a user has authenticated to one opted-in app, subsequent logins to other opted-in apps skip the email step — the user just enters their email address and is signed in immediately.
Two conditions must both be true for SSO to fire on a given login:
sso_session_enabled = true in server configThis allows gradual rollout — enable globally but only opt in specific apps. The user always enters their email, preserving the ability to switch accounts. Logout fully clears the SSO session.
# Step 1 — Enable SSO globally (global_admin)
curl -X PUT http://localhost:8080/api/admin/config \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{"sso_session_enabled": "true", "sso_session_ttl_hours": "168"}'
# Step 2 — Opt a client in (app_admin or global_admin)
curl -X PUT http://localhost:8080/api/admin/clients/<client_id>/sso \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
# Check SSO status for a client
curl http://localhost:8080/api/admin/clients/<client_id>/sso \
-H "Authorization: Bearer <token>"
# {"client_id":"...","enabled":true,"global_sso_enabled":true,...}
Settings are stored in the database and take effect immediately — no restart required.
curl -X PUT http://localhost:8080/api/admin/config \
-H "Authorization: Bearer <global_admin_token>" \
-H "Content-Type: application/json" \
-d '{
"sso_session_enabled": "true",
"sso_session_ttl_hours": "720",
"registration_open": "true",
"registration_allowed_domains": "app.example.com"
}'
| Key | Type | Default | Description |
|---|---|---|---|
sso_session_enabled | bool | false | Enable SSO session sharing |
sso_session_ttl_hours | int | 720 | SSO session lifetime in hours (30 days) |
registration_open | bool | true | Allow new clients to self-register via /oauth/register |
registration_allowed_domains | string | "" | Comma-separated domains allowed in redirect_uri. Empty = any domain. |
magic-auth publishes to NATS JetStream whenever a magic link needs to be sent. Your email service subscribes and delivers however you like — SMTP, SendGrid, Resend, SES, Postmark, etc.
Payload (JSON):
{
"to": ["[email protected]"],
"subject": "Your sign-in link",
"body": "Click the link below to sign in:\n\nhttps://oidc.example.com/api/auth/verify?id=...&token=...",
"is_html": false,
"headers": {
"From": "[email protected]",
"X-Mailer": "magiclink-auth"
}
}
The magic link in body is valid for 15 minutes and single-use.
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: "nats://localhost:4222" });
const js = nc.jetstream();
const sc = StringCodec();
const consumer = await js.consumers.get("EMAILS", "email-sender");
for await (const msg of await consumer.consume()) {
const payload = JSON.parse(sc.decode(msg.data));
await sendEmail({
to: payload.to,
subject: payload.subject,
text: payload.body,
from: payload.headers["From"],
});
msg.ack();
}
nc, _ := nats.Connect("nats://localhost:4222")
js, _ := nc.JetStream()
js.Subscribe("emails.send", func(msg *nats.Msg) {
var m struct {
To []string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
Headers map[string]string `json:"headers"`
}
json.Unmarshal(msg.Data, &m)
// deliver email using m.To, m.Subject, m.Body, m.Headers["From"]
msg.Ack()
}, nats.Durable("email-sender"), nats.ManualAck())
curl http://localhost:8080/api/health
# {"status":"ok"}
Content type
Image
Digest
sha256:b6f760499…
Size
7.8 MB
Last updated
5 months ago
docker pull jlcox1970/magiclink-auth:1.0.1