Sign inSign up

jlcox1970/magiclink-auth

By jlcox1970

Updated 5 months ago

OIDC Provider

Image
Security
0

395

jlcox1970/magiclink-auth repository overview

magic-auth

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.


Quick Start

# 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:

Generate a Signing Key

# 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)"

Environment Variables

VariableRequiredDefaultDescription
ISSUERPublic URL of this server. Included in JWT iss claim.
JWT_SECRETHMAC secret for session cookies. Minimum 32 characters.
FINGERPRINT_SECRETSecret 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_DRIVERrqliterqlite or postgres
RQLITE_URLhttp://rqlite:4001rqlite HTTP endpoint
POSTGRES_DSNPostgreSQL DSN e.g. postgres://user:pass@host/db?sslmode=require
NATS_URLe.g. nats://nats:4222
NATS_STREAM_NAMEEMAILSJetStream stream name
NATS_SUBJECTemails.sendSubject to publish email messages on
NATS_STREAM_MAX_BYTES134217728Stream storage cap in bytes (128 MiB)
NATS_STREAM_MAX_AGE24hMaximum age of retained messages
FROM_ADDRESS[email protected]From address in magic link emails
BASE_URLhttp://localhost:8080Base URL used when constructing magic link URLs
LOGIN_UI_URLExternal login UI URL. If empty, built-in UI served at /login-ui.
CORS_ORIGINSSpace-separated allowed CORS origins
ALLOWED_REDIRECT_DOMAINSComma-separated domains permitted in OIDC redirect_uri. Empty = any domain.
SECURE_COOKIEStrueSet false for local HTTP development.
LOG_LEVELinfodebug, info, warn, error
PORT8080Listen port
BCRYPT_COST12bcrypt work factor for token hashing
RBAC_RULESJSON 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.


OIDC Endpoints

EndpointDescription
GET /.well-known/openid-configurationDiscovery document
GET /.well-known/jwks.jsonPublic key set
POST /oauth/registerDynamic client registration (RFC 7591)
GET /oauth/authorizeStart the authorization code flow
POST /oauth/tokenExchange code for tokens / rotate refresh token
GET /oauth/userinfoReturns claims for the token holder
POST /oauth/revokeToken revocation (RFC 7009) — also clears SSO session
Registering a Client
# 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.

Token Claims
{
  "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

Role System

Every user has one or more roles embedded in their JWT at issuance time.

RoleDescription
global_adminFull access to all users, clients, and server configuration
app_adminScoped to their own client — manages users who have logged into their app
userSelf-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.

Bootstrapping a global_admin

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"]
  }'

SSO Session Sharing

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:

  1. Global toggle onsso_session_enabled = true in server config
  2. Client opted in — the destination client has SSO enabled

This 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,...}

Server Configuration

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"
  }'
KeyTypeDefaultDescription
sso_session_enabledboolfalseEnable SSO session sharing
sso_session_ttl_hoursint720SSO session lifetime in hours (30 days)
registration_openbooltrueAllow new clients to self-register via /oauth/register
registration_allowed_domainsstring""Comma-separated domains allowed in redirect_uri. Empty = any domain.

NATS Email Payload

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.

Example Consumer — Node.js
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();
}
Example Consumer — Go
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())

Health Check

curl http://localhost:8080/api/health
# {"status":"ok"}

Tag summary

Content type

Image

Digest

sha256:b6f760499

Size

7.8 MB

Last updated

5 months ago

docker pull jlcox1970/magiclink-auth:1.0.1