Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Production-ready OAuth 2.0 Server and OpenID Connect 1.0 Provider
Authority is a complete authentication infrastructure built with Crystal, featuring enterprise-grade security and a modern admin dashboard.
Get Authority running in 5 minutes:
See for a complete walkthrough.
Welcome to Authority tutorials. These step-by-step guides will teach you how to use Authority for OAuth 2.0 authentication.
Get Authority running in 5 minutes using Docker. You'll have a working OAuth server by the end.
Prerequisites: Docker installed Duration: 5 minutes
Build a complete OAuth 2.0 integration from scratch. You'll create a client application, implement the authorization code flow, and access protected resources.
Prerequisites: Completed Quick Start Duration: 30 minutes
Learn how to secure your API endpoints using Authority's token validation. Implement access control with scopes.
Prerequisites: Completed First OAuth Integration Duration: 20 minutes
Implement user login and registration flows in your application using OpenID Connect.
Get Authority running in 5 minutes. By the end, you'll have a working OAuth 2.0 server.
installed
installed
This starts:
Authority server on port 4000
Build a complete OAuth 2.0 integration from scratch. You'll implement the authorization code flow with PKCE.
Completed
Authority running on http://localhost:4000
An OAuth client created with:
Learn how to secure your API endpoints using Authority's token validation.
Completed
A backend API you want to protect
Validate access tokens server-side
By completing these tutorials, you will understand:
How to deploy and configure Authority
OAuth 2.0 authorization code flow with PKCE
Token validation and introspection
OpenID Connect user authentication
Scope-based access control
If you get stuck:
Check the How-To Guides for specific tasks
Review the Reference for technical details
Read the Explanation for conceptual understanding
Authorization Code, PKCE, Client Credentials, Device Flow, Refresh Tokens
OpenID Connect
ID Tokens, UserInfo, Discovery, JWKS
Security
MFA/TOTP, Account Lockout, Password Policies, Audit Logging
Admin
Client Management, User Management, Scope Configuration, Settings
This documentation is organized using the Diataxis framework:
Step-by-step guides for learning Authority:
Quick Start - Get running in 5 minutes
First OAuth Integration - Build your first OAuth app
Protect Your API - Secure your endpoints
Add User Authentication - Implement login flows
Task-oriented guides for specific goals:
Installation - Docker, source, Kubernetes
Configuration - Environment setup
Security - MFA, lockout, passwords
OAuth Clients - Client management
Technical specifications and API documentation:
OAuth 2.0 Flows - Grant type specifications
OpenID Connect - OIDC endpoints
API Endpoints - Complete API reference
Configuration - All settings
Understanding concepts and architecture:
Architecture - System design
OAuth 2.0 Concepts - Protocol fundamentals
Security Model - Security architecture
Choosing Grant Types - Decision guide
Authority implements these specifications:
Redirect URI: http://localhost:3000/callback
A simple web application that:
Redirects users to Authority for authentication
Receives an authorization code
Exchanges the code for access tokens
Uses tokens to access protected resources
PKCE protects against authorization code interception. Generate a code verifier and challenge:
Build the authorization URL and redirect the user:
After the user authenticates, Authority redirects to your callback URL:
Make a POST request to the token endpoint:
The response contains:
Include the token in API requests:
Here's a minimal HTML page implementing the full flow:
Protect Your API - Validate tokens on your server
Authorization Code Reference - Full specification
PKCE Reference - PKCE details
// Generate random code verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
// Create code challenge from verifier
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(hash));
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}const CLIENT_ID = 'your_client_id';
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTHORITY_URL = 'http://localhost:4000';
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateCodeVerifier(); // Random state for CSRF protection
// Store for later
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${AUTHORITY_URL}/authorize?${params}`;
}// On your callback page
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Verify state matches
if (state !== sessionStorage.getItem('state')) {
throw new Error('Invalid state parameter');
}
// Get stored code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
// Exchange code for tokens
const tokens = await exchangeCode(code, codeVerifier);
console.log('Access token:', tokens.access_token);
}async function exchangeCode(code, codeVerifier) {
const response = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier
})
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return response.json();
}{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "openid profile email"
}async function fetchUserInfo(accessToken) {
const response = await fetch(`${AUTHORITY_URL}/userinfo`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
return response.json();
}
// Result:
// {
// "sub": "user-uuid",
// "name": "John Doe",
// "email": "john@example.com"
// }<!DOCTYPE html>
<html>
<head>
<title>OAuth Demo</title>
</head>
<body>
<button id="login">Login with Authority</button>
<div id="user"></div>
<script>
const CLIENT_ID = 'your_client_id';
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTHORITY_URL = 'http://localhost:4000';
// Check if this is a callback
if (window.location.search.includes('code=')) {
handleCallback();
}
document.getElementById('login').onclick = login;
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${AUTHORITY_URL}/authorize?${params}`;
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('state')) {
alert('Invalid state');
return;
}
const codeVerifier = sessionStorage.getItem('code_verifier');
const tokenResponse = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier
})
});
const tokens = await tokenResponse.json();
const userResponse = await fetch(`${AUTHORITY_URL}/userinfo`, {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});
const user = await userResponse.json();
document.getElementById('user').textContent = `Welcome, ${user.name}!`;
}
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(hash));
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
</script>
</body>
</html>PostgreSQL database on port 5432
Open your browser to http://localhost:4000.
You should see the Authority landing page:
Click Sign In and use the default admin credentials:
Username: admin@example.com
Password: password123
After signing in, you can:
Manage OAuth Clients - Register applications
Manage Users - Create and edit accounts
Configure Scopes - Define access permissions
View Audit Logs - Track all actions
Adjust Settings - Configure security policies
Navigate to OAuth Clients
Click New Client
Fill in:
Name: My Test App
Redirect URI: http://localhost:3000/callback
Click Create
You'll receive a client_id and client_secret. Save these for the next tutorial.
First OAuth Integration - Build a complete OAuth flow
Docker Installation Guide - Production deployment options
Environment Variables - Configuration options
Stop the conflicting service or change the port:
Ensure PostgreSQL is running:
Check Authority logs:
git clone https://github.com/azutoolkit/authority.git
cd authoritydocker-compose up -dPORT=4001 docker-compose up -ddocker-compose logs dbdocker-compose logs authorityImplement scope-based access control
Handle token expiration gracefully
There are two ways to validate tokens:
Local validation - Verify JWT signature using Authority's public keys
Token introspection - Ask Authority if the token is valid
Local validation
Low latency required, stateless validation
Token introspection
Need real-time revocation checks, opaque tokens
Authority publishes its public keys at /.well-known/jwks.json:
Node.js Example:
Python Example:
Express.js Middleware:
For real-time validation, use the introspection endpoint.
Token introspection requires client authentication:
The response:
If the token is invalid or revoked:
Configure scopes in Authority's admin dashboard:
read
Read-only access
write
Create and update resources
admin
Add User Authentication - OpenID Connect integration
Token Introspection Reference - Full specification
Refresh Tokens - Token renewal
curl http://localhost:4000/.well-known/jwks.json{
"keys": [
{
"kty": "RSA",
"kid": "authority-key-1",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'http://localhost:4000/.well-known/jwks.json',
cache: true,
rateLimit: true
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
async function validateToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'http://localhost:4000'
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
}import jwt
import requests
from jwt import PyJWKClient
jwks_client = PyJWKClient("http://localhost:4000/.well-known/jwks.json")
def validate_token(token):
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer="http://localhost:4000"
)async function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.substring(7);
try {
const decoded = await validateToken(token);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Use it
app.get('/api/protected', authMiddleware, (req, res) => {
res.json({ message: `Hello, ${req.user.sub}` });
});const CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';async function introspectToken(token) {
const credentials = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
const response = await fetch('http://localhost:4000/token/introspect', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
},
body: new URLSearchParams({
token: token,
token_type_hint: 'access_token'
})
});
return response.json();
}{
"active": true,
"client_id": "my-client",
"username": "user@example.com",
"scope": "read write",
"sub": "user-uuid",
"exp": 1699999999
}{
"active": false
}function requireScopes(...requiredScopes) {
return async (req, res, next) => {
const token = req.headers.authorization?.substring(7);
if (!token) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const decoded = await validateToken(token);
const tokenScopes = decoded.scope?.split(' ') || [];
const hasAllScopes = requiredScopes.every(
scope => tokenScopes.includes(scope)
);
if (!hasAllScopes) {
return res.status(403).json({
error: 'Insufficient scope',
required: requiredScopes,
provided: tokenScopes
});
}
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
};
}
// Use it
app.get('/api/data', requireScopes('read'), (req, res) => {
res.json({ data: '...' });
});
app.post('/api/data', requireScopes('write'), (req, res) => {
res.json({ created: true });
});
app.delete('/api/users/:id', requireScopes('admin'), (req, res) => {
res.json({ deleted: true });
});Deploy Authority using Docker for production environments.
Docker 20.10+
Docker Compose 2.0+
1GB RAM minimum
PostgreSQL (included in docker-compose)
Authority is now running at http://localhost:4000.
Create a .env file:
Create docker-compose.yml:
Run migrations manually if needed:
For high availability, run multiple Authority instances behind a load balancer:
Check logs:
Verify PostgreSQL is running:
Change the port:
- All configuration options
- Enable HTTPS
- Session storage
Learn how to add "Sign in with Google" to your application in under 10 minutes.
By the end of this tutorial, you'll have:
Google OAuth configured in Authority
A working "Sign in with Google" button
Users automatically created from Google accounts
Authority running locally or deployed
A Google account for testing
Basic web development knowledge
Go to
Create a new project:
Click the project dropdown at the top
Click New Project
Log in to your Authority admin dashboard at http://localhost:4000/dashboard
Navigate to Settings > Social Login
Find the Google section and configure:
Open a new incognito/private browser window
Navigate to http://localhost:4000/auth/google
You should be redirected to Google's sign-in page
Sign in with your Google account
Add a sign-in button to your app:
After successful authentication, redirect users to your app:
After authentication, Authority will redirect users to your specified URL with an active session.
Now that Google is working, add more providers:
GitHub:
Facebook:
LinkedIn:
Apple:
Your callback URL doesn't match Google's configuration.
Fix: Ensure the redirect URI in Google Console exactly matches:
You're not a test user for an unverified app.
Fix: In Google Console, go to OAuth consent screen > Test users and add your email.
Check that your forward_url is correctly encoded.
Fix: Use btoa() to Base64 encode the URL.
- Let users link multiple providers
- Add extra security
- Full OAuth client setup
Implement user login and registration in your application using OpenID Connect.
Completed First OAuth Integration
Understanding of OAuth 2.0 authorization code flow
Use OpenID Connect for user authentication
Parse and validate ID tokens
Display user profile information
Handle sessions and logout
OpenID Connect adds an identity layer on top of OAuth 2.0. Instead of just getting an access token, you also receive an ID token containing user information.
Add openid and profile scopes to your authorization request:
The token response now includes an ID token:
Decode the ID token (it's a JWT):
Always validate ID tokens before trusting them:
Use the UserInfo endpoint for more claims:
Authority supports OpenID Connect RP-Initiated Logout:
- Full specification
- Token structure
- Available claims
Build and run Authority from source code.
Crystal 1.9+
PostgreSQL 13+
Git
git clone https://github.com/azutoolkit/authority.git
cd authorityshards installCreate the database:
createdb authority_dbOr using psql:
psql -c "CREATE DATABASE authority_db;"Copy the example environment file:
Edit .env.local:
Create an admin user:
Development mode:
Or with hot reload:
Run specific tests:
Use watchexec or similar:
Ensure Crystal is in your PATH:
Start PostgreSQL:
Update shards cache:
Check what's using the port:
- Containerized deployment
- Database configuration
- All settings
Deploy Authority on Kubernetes for scalable, production-ready authentication.
Kubernetes cluster (1.24+)
kubectl configured
Helm 3 (optional)
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: authoritykubectl apply -f namespace.yaml# Generate secret key
kubectl create secret generic authority-secrets \
--namespace authority \
--from-literal=secret-key=$(openssl rand -hex 32) \
--from-literal=db-password=$(openssl rand -hex 16)# postgres.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: authority
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: authority
- name: POSTGRES_DB
value: authority_db
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: authority-secrets
key: db-password
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command: ["pg_isready", "-U", "authority"]
initialDelaySeconds: 30
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: authority
spec:
ports:
- port: 5432
selector:
app: postgres
clusterIP: None# authority.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: authority
namespace: authority
spec:
replicas: 3
selector:
matchLabels:
app: authority
template:
metadata:
labels:
app: authority
spec:
containers:
- name: authority
image: azutoolkit/authority:latest
ports:
- containerPort: 4000
env:
- name: CRYSTAL_ENV
value: production
- name: PORT
value: "4000"
- name: BASE_URL
value: "https://auth.example.com"
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: authority-secrets
key: secret-key
- name: DATABASE_URL
value: "postgres://authority:$(DB_PASSWORD)@postgres:5432/authority_db"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: authority-secrets
key: db-password
livenessProbe:
httpGet:
path: /health
port: 4000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 4000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: authority
namespace: authority
spec:
ports:
- port: 80
targetPort: 4000
selector:
app: authority# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authority
namespace: authority
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- auth.example.com
secretName: authority-tls
rules:
- host: auth.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: authority
port:
number: 80kubectl apply -f namespace.yaml
kubectl apply -f postgres.yaml
kubectl apply -f authority.yaml
kubectl apply -f ingress.yamlhelm repo add authority https://azutoolkit.github.io/authority-helm
helm repo updatehelm install authority authority/authority \
--namespace authority \
--create-namespace \
--set ingress.enabled=true \
--set ingress.hosts[0].host=auth.example.com \
--set postgresql.enabled=trueCreate values.yaml:
Install with values:
Use a managed PostgreSQL service (RDS, Cloud SQL) or deploy a PostgreSQL cluster:
View logs:
- Configuration options
- TLS configuration
- Multi-factor authentication
Configure Authority using environment variables.
CRYSTAL_ENV
development
Environment: development, production, test
Example connection strings:
Generate a secure key:
Authority automatically loads .env.local in development:
Or with env file:
- Database configuration
- Session storage
- HTTPS setup
Configure PostgreSQL for Authority.
PostgreSQL 13 or higher
100MB minimum disk space
Recommended: SSD storage
# Create database
psql -c "CREATE DATABASE authority_db;"
# Create user (optional)
psql -c "CREATE USER authority WITH PASSWORD 'secure_password';"
psql -c "GRANT ALL PRIVILEGES ON DATABASE authority_db TO authority;"createdb authority_dbConfigure the DATABASE_URL environment variable:
# Basic format
DATABASE_URL=postgres://user:password@host:port/database
# Examples
DATABASE_URL=postgres://localhost:5432/authority_db
DATABASE_URL=postgres://authority:password@localhost:5432/authority_dbFor production, enable SSL:
SSL modes:
disable - No SSL
require - SSL required, no verification
verify-ca - Verify server certificate
Migrations create the required tables:
Create initial data:
This creates:
Default admin user
Common OAuth scopes
System settings
With compression:
From compressed:
For production, configure connection pooling:
Update connection string:
Authority creates necessary indexes during migration. For additional performance, consider:
Check PostgreSQL is running:
Verify credentials and pg_hba.conf:
Create the database:
Grant permissions:
- All configuration options
- Session storage
- Containerized database
Configure Redis for session storage and caching.
Session storage - Share sessions across multiple Authority instances
Token caching - Faster token validation
Rate limiting - Distributed rate limit counters
Redis 6.0+
Network access between Authority and Redis
Docker:
macOS:
Ubuntu:
Set the Redis URL:
With password:
With database number:
With Redis enabled, sessions are stored in Redis instead of PostgreSQL:
Session expiry is handled automatically by Redis TTL.
Access tokens are cached for faster validation:
Rate limit counters use Redis:
Check Redis is running:
Verify password:
Check memory usage:
Increase maxmemory or use eviction policy.
- All configuration options
- Containerized setup
- Scalable deployment
Enable HTTPS for Authority.
There are two approaches to enable HTTPS:
Reverse proxy (recommended) - Terminate SSL at Nginx/Traefik
Direct SSL - Authority handles SSL directly
Install Certbot:
sudo apt install certbot python3-certbot-nginxGet certificate:
sudo certbot --nginx -d auth.example.comNginx configuration:
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
# SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name auth.example.com;
return 301 https://$server_name$request_uri;
}Update Authority configuration:
Docker labels:
Authority can handle SSL directly using environment variables.
Certbot sets up automatic renewal. Test with:
Create renewal hook to reload Authority:
Add security headers in your reverse proxy:
Test your SSL setup:
Include the full chain:
Ensure Authority can read certificates:
Ensure BASE_URL uses https://:
- All configuration options
- Production setup
- Additional security
Set up TOTP-based two-factor authentication for Authority users.
Authority supports Time-based One-Time Passwords (TOTP) for MFA. Users can use apps like:
Google Authenticator
Authy
1Password
Microsoft Authenticator
Log in to Authority
Click your profile name
Select Security Settings
Click Enable Two-Factor Authentication
Authority displays a QR code
Open your authenticator app
Tap Add Account or +
Scan the QR code
Enter the 6-digit code from your app
Click Verify
Save your backup codes in a secure location
When MFA is enabled, Authority generates 10 one-time backup codes. Store these securely - they can be used if you lose access to your authenticator app.
Example backup codes:
Each code can only be used once.
Enforce MFA for all administrator accounts:
Force MFA enrollment for all users:
Allow users time to set up MFA:
Users can disable MFA from their profile if allowed:
Navigate to Security Settings
Click Disable Two-Factor Authentication
Enter current TOTP code to confirm
Administrators can disable MFA for users:
Go to Admin Dashboard → Users
Select the user
Click Disable MFA
This is useful when a user loses access to their authenticator.
Response:
During OAuth authorization, check if MFA is required:
If a user loses their phone:
Use a backup code to log in
Disable MFA
Set up MFA again with new device
If a user loses both authenticator and backup codes:
Admin disables MFA for the user
User logs in with password only
User sets up MFA again
User saves new backup codes
Verify device time is synchronized
Ensure you're using the correct account in the authenticator
Wait for the next code cycle (30 seconds)
Use the manual entry option
Enter the secret key directly into your authenticator
Use a backup code
Contact administrator to disable MFA
- Brute-force protection
- Password requirements
- Track security events
Protect against brute-force attacks with account lockout.
After a configured number of failed login attempts, the account is temporarily locked:
LOCKOUT_THRESHOLD
5
For enhanced security, increase lockout duration with each lockout:
Navigate to Admin Dashboard → Users
Filter by Status: Locked
Select the locked user
Click Unlock Account
Optionally, send password reset email
Lockout events are logged:
Set up alerts for lockout events:
Response:
Optionally lock by IP address instead of account:
This helps when attackers try multiple usernames from the same IP.
Exclude certain IPs from lockout:
Users see a clear message when locked:
Offer password reset as an alternative:
Check for password manager issues
Verify caps lock behavior
Consider increasing threshold
Verify environment variables are loaded
Check ENABLE_LOCKOUT is not set to false
Review audit logs for failed attempts
Verify ENABLE_AUTO_UNLOCK=true
Check server time synchronization
Review logs for errors
- Multi-factor authentication
- Password requirements
- Track security events
Configure password requirements for Authority users.
PASSWORD_MIN_LENGTH
12
Minimum password length
When a user sets a password, Authority validates:
Length - Meets minimum requirement
Complexity - Contains required character types
History - Not recently used
Strength - Not in common password lists (optional)
Users see clear feedback:
When PASSWORD_EXPIRY_DAYS is set, users must change passwords periodically.
Users are warned before expiry:
Allow logins during grace period:
When a password expires:
User logs in with expired password
Authority forces password change
User cannot access application until password is changed
Prevent password reuse:
If a user tries to reuse a password:
Block commonly used passwords:
Download a password list:
Response:
Response:
The UI shows password strength in real-time:
Weak - Red, doesn't meet requirements
Fair - Yellow, meets minimum
Strong - Green, exceeds requirements
Offer a password generator:
Check which rules are enabled
Verify character encoding
Review password history
Consider reducing complexity requirements
Enable password manager hints
Implement "forgot password" flow
- Multi-factor authentication
- Brute-force protection
- Track security events
Track all security-relevant actions in Authority.
Authority logs all significant events to help with:
Security monitoring
Compliance requirements
Incident investigation
Enable users to sign in with their GitHub accounts.
Authority instance running
Admin access to Authority dashboard
GitHub account
Enable users to sign in with their Facebook accounts.
Authority instance running
Admin access to Authority dashboard
Facebook Developer account
Enable users to sign in with their LinkedIn accounts.
Authority instance running
Admin access to Authority dashboard
LinkedIn Developer account
Enable users to sign in with their Apple ID.
Authority instance running
Admin access to Authority dashboard
Apple Developer account ($99/year membership required)
Apple Sign-In is more complex than other providers because:
Allow users to connect and disconnect social accounts from their profile.
Users can:
Link multiple social providers to one account
Unlink social providers they no longer want
Sign in with any linked provider
Create and configure OAuth clients in Authority.
OAuth clients represent applications that can request access tokens. Each client has:
Client ID - Public identifier
Client Secret - Confidential key (for confidential clients)
Redirect URIs - Allowed callback URLs
Create and manage OAuth scopes for access control.
Scopes define what access a client can request:
Standard scopes - OpenID Connect scopes (openid, profile, email)
Custom scopes - Application-specific access levels (read, write, admin)
Authority includes these standard scopes:
Configure social authentication providers to allow users to sign in with their existing accounts from Google, GitHub, Facebook, LinkedIn, and Apple.
- Enable Google Sign-In
- Enable GitHub authentication
- Enable Facebook Login
Enable users to sign in with their Google accounts.
Authority instance running
Admin access to Authority dashboard
Google Cloud Console account
Administrative operations
Language
Crystal
Web Framework
Azu
Database
PostgreSQL
Templating
Crinja (Jinja2-compatible)
Caching
Redis (optional)


OAuth 2.0



DEVICE_CODE_TTL
300
Device code lifetime (seconds)
SSL_CA
Path to CA certificate
SSL_MODE
SSL mode: require, verify-ca, verify-full
PORT
4000
HTTP server port
HOST
0.0.0.0
Bind address
BASE_URL
http://localhost:4000
Public URL for redirects and links
CRYSTAL_WORKERS
4
Number of worker processes
DATABASE_URL
Required
PostgreSQL connection string
SECRET_KEY
Required
JWT signing key (256-bit minimum)
ACCESS_TOKEN_TTL
3600
Access token lifetime (seconds)
CODE_TTL
600
SESSION_KEY
session_id
Session cookie name
SESSION_DURATION_DAYS
7
SSL_CERT
Path to SSL certificate
SSL_KEY
TEMPLATES_PATH
./public/templates
Path to Jinja templates
CRYSTAL_LOG_LEVEL
debug
Log level: debug, info, warn, error
CRYSTAL_LOG_SOURCES
*
Authorization code lifetime (seconds)
Session lifetime (days)
Path to SSL private key
Log sources filter
verify-full - Verify server certificate and hostnamerefresh_tokens
Refresh tokens
authorization_codes
Authorization codes
device_codes
Device authorization codes
scopes
OAuth scopes
audit_logs
Audit trail
sessions
User sessions
users
User accounts
clients
OAuth clients
access_tokens
Access tokens
PASSWORD_HISTORY_COUNT
5
Prevent reuse of recent passwords
PASSWORD_EXPIRY_DAYS
0
Days until password expires (0 = never)
REQUIRE_UPPERCASE
true
Require uppercase letters
REQUIRE_LOWERCASE
true
Require lowercase letters
REQUIRE_NUMBERS
true
Require numeric digits
REQUIRE_SPECIAL
false
Require special characters
function scheduleTokenRefresh(tokens) {
const expiresIn = tokens.expires_in * 1000; // Convert to ms
const refreshAt = expiresIn - 60000; // Refresh 1 minute early
setTimeout(async () => {
const newTokens = await refreshAccessToken(tokens.refresh_token);
scheduleTokenRefresh(newTokens);
}, refreshAt);
}app.use((err, req, res, next) => {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'token_expired',
message: 'Access token has expired'
});
}
next(err);
});# Clone the repository
git clone https://github.com/azutoolkit/authority.git
cd authority
# Start with Docker
docker-compose up -d
# Visit http://localhost:4000git clone https://github.com/azutoolkit/authority.git
cd authority
docker-compose up -d# Server
CRYSTAL_ENV=production
PORT=4000
BASE_URL=https://auth.example.com
# Database
DATABASE_URL=postgres://auth_user:secure_password@db:5432/authority_db
# Security
SECRET_KEY=your-256-bit-secret-key-here
# Token Lifetimes (seconds)
ACCESS_TOKEN_TTL=3600
CODE_TTL=600
DEVICE_CODE_TTL=300
# SSL (optional - use reverse proxy recommended)
SSL_CERT=
SSL_KEY=openssl rand -hex 32version: '3.8'
services:
authority:
image: azutoolkit/authority:latest
ports:
- "4000:4000"
environment:
- CRYSTAL_ENV=production
- DATABASE_URL=postgres://auth_user:${DB_PASSWORD}@db:5432/authority_db
- SECRET_KEY=${SECRET_KEY}
- BASE_URL=${BASE_URL}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=auth_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=authority_db
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U auth_user -d authority_db"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:docker-compose up -dserver {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / {
proxy_pass http://localhost:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name auth.example.com;
return 301 https://$server_name$request_uri;
}# docker-compose.yml addition
labels:
- "traefik.enable=true"
- "traefik.http.routers.authority.rule=Host(`auth.example.com`)"
- "traefik.http.routers.authority.tls=true"
- "traefik.http.routers.authority.tls.certresolver=letsencrypt"docker-compose exec authority crystal run src/db/migrate.crdocker-compose exec db pg_dump -U auth_user authority_db > backup.sqldocker-compose exec -T db psql -U auth_user authority_db < backup.sqlcurl http://localhost:4000/healthdocker-compose logs -f authoritydocker stats authorityservices:
authority:
deploy:
replicas: 3docker-compose logs authoritydocker-compose ps db
docker-compose logs dbPORT=4001 docker-compose up -dcp .env.example .env.local# Server
CRYSTAL_ENV=development
PORT=4000
BASE_URL=http://localhost:4000
# Database
DATABASE_URL=postgres://localhost:5432/authority_db
# Security
SECRET_KEY=development-secret-key-change-in-production
# Token Lifetimes
ACCESS_TOKEN_TTL=3600
CODE_TTL=600crystal run src/db/migrate.crcrystal run src/db/seed.crcrystal run src/app.cr./scripts/dev.shcrystal build src/app.cr --release -o bin/authorityCRYSTAL_ENV=production ./bin/authoritycrystal speccrystal spec spec/models/user_spec.crwatchexec -e cr -r "crystal run src/app.cr"dropdb authority_db
createdb authority_db
crystal run src/db/migrate.cr
crystal run src/db/seed.crauthority/
├── src/
│ ├── app.cr # Application entry point
│ ├── db/
│ │ ├── migrate.cr # Migration runner
│ │ ├── seed.cr # Seed data
│ │ └── migrations/ # Migration files
│ ├── endpoints/ # HTTP handlers
│ ├── models/ # Data models
│ ├── services/ # Business logic
│ └── views/ # Response serializers
├── public/
│ ├── templates/ # Jinja templates
│ ├── css/ # Stylesheets
│ └── js/ # JavaScript
├── spec/ # Tests
├── shard.yml # Dependencies
└── .env.example # Environment templateexport PATH="$PATH:/usr/local/crystal/bin"# macOS
brew services start postgresql
# Linux
sudo systemctl start postgresqlrm -rf lib .shards
shards installlsof -i :4000replicaCount: 3
image:
repository: azutoolkit/authority
tag: latest
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: auth.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: authority-tls
hosts:
- auth.example.com
postgresql:
enabled: true
auth:
database: authority_db
username: authority
primary:
persistence:
size: 10Gi
redis:
enabled: true
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 80helm install authority authority/authority \
--namespace authority \
--create-namespace \
-f values.yamlpostgresql:
enabled: false
externalDatabase:
host: your-postgres.xxx.rds.amazonaws.com
port: 5432
database: authority_db
username: authority
existingSecret: authority-db-secretredis:
enabled: true
architecture: replication
replica:
replicaCount: 2apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: authority-pdb
namespace: authority
spec:
minAvailable: 2
selector:
matchLabels:
app: authorityapiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: authority
namespace: authority
spec:
selector:
matchLabels:
app: authority
endpoints:
- port: http
path: /metricskubectl logs -f -l app=authority -n authoritykubectl get pods -n authority
kubectl describe pod authority-xxx -n authoritykubectl exec -it authority-xxx -n authority -- sh
curl postgres:5432kubectl get events -n authority --sort-by='.lastTimestamp'# Local development
DATABASE_URL=postgres://localhost:5432/authority_db
# With credentials
DATABASE_URL=postgres://user:password@localhost:5432/authority_db
# With SSL
DATABASE_URL=postgres://user:password@host:5432/authority_db?sslmode=requireopenssl rand -hex 32# .env.local
CRYSTAL_ENV=development
PORT=4000
BASE_URL=http://localhost:4000
DATABASE_URL=postgres://localhost:5432/authority_db
SECRET_KEY=development-key-not-for-production
CRYSTAL_LOG_LEVEL=debug# .env
CRYSTAL_ENV=production
PORT=4000
HOST=0.0.0.0
BASE_URL=https://auth.example.com
DATABASE_URL=postgres://user:password@db.example.com:5432/authority_db?sslmode=require
SECRET_KEY=your-256-bit-production-secret-key
ACCESS_TOKEN_TTL=3600
SESSION_DURATION_DAYS=7
CRYSTAL_LOG_LEVEL=info
CRYSTAL_WORKERS=8crystal run src/app.crexport DATABASE_URL=postgres://localhost:5432/authority_db
crystal run src/app.crdocker run -e DATABASE_URL=... -e SECRET_KEY=... azutoolkit/authoritydocker run --env-file .env azutoolkit/authorityDATABASE_URL=postgres://user:password@host:5432/authority_db?sslmode=requirecrystal run src/db/migrate.crcrystal run src/db/seed.crpg_dump -U authority authority_db > backup.sqlpg_dump -U authority authority_db | gzip > backup.sql.gzpsql -U authority authority_db < backup.sqlgunzip -c backup.sql.gz | psql -U authority authority_dbDATABASE_URL=postgres://user:password@host:5432/authority_db?initial_pool_size=10&max_pool_size=50# pgbouncer.ini
[databases]
authority_db = host=localhost port=5432 dbname=authority_db
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20DATABASE_URL=postgres://user:password@localhost:6432/authority_db# postgresql.conf
# Memory
shared_buffers = 256MB
effective_cache_size = 768MB
work_mem = 16MB
# Connections
max_connections = 100
# Write-ahead log
wal_buffers = 16MB
checkpoint_completion_target = 0.9-- Index on frequently queried columns
CREATE INDEX idx_tokens_user_id ON access_tokens(user_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);pg_isready -h localhost -p 5432# pg_hba.conf
local all all trust
host all all 127.0.0.1/32 md5createdb authority_dbGRANT ALL PRIVILEGES ON DATABASE authority_db TO authority;
GRANT ALL ON ALL TABLES IN SCHEMA public TO authority;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authority;docker run -d -p 6379:6379 redis:7-alpinebrew install redis
brew services start redissudo apt install redis-server
sudo systemctl enable redis
sudo systemctl start redisREDIS_URL=redis://localhost:6379REDIS_URL=redis://:password@localhost:6379REDIS_URL=redis://localhost:6379/1# redis.conf
# Memory
maxmemory 256mb
maxmemory-policy allkeys-lru
# Persistence
save 900 1
save 300 10
save 60 10000
# Security
requirepass your_redis_password
bind 127.0.0.1
# Performance
tcp-keepalive 300REDIS_URL=rediss://user:password@redis.example.com:6380version: '3.8'
services:
authority:
image: azutoolkit/authority
environment:
- REDIS_URL=redis://redis:6379
depends_on:
- redis
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:REDIS_URL=redis://sentinel1:26379,sentinel2:26379,sentinel3:26379/mymasterREDIS_URL=redis://node1:6379,node2:6379,node3:6379authority:session:{session_id} -> {user_id, created_at, ...}authority:token:{token_hash} -> {user_id, scope, exp, ...}authority:ratelimit:{ip}:{endpoint} -> countredis-cli
# Check memory usage
INFO memory
# List keys
KEYS authority:*
# Monitor commands
MONITORredis-cli -h localhost -p 6379 pingredis-cli pingredis-cli -a your_password pingredis-cli INFO memoryBASE_URL=https://auth.example.com# traefik.yml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: weblabels:
- "traefik.enable=true"
- "traefik.http.routers.authority.rule=Host(`auth.example.com`)"
- "traefik.http.routers.authority.entrypoints=websecure"
- "traefik.http.routers.authority.tls.certresolver=letsencrypt"openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout authority.key \
-out authority.crt \
-subj "/CN=localhost"SSL_CERT=/path/to/authority.crt
SSL_KEY=/path/to/authority.key
BASE_URL=https://localhost:4000SSL_CERT=/etc/letsencrypt/live/auth.example.com/fullchain.pem
SSL_KEY=/etc/letsencrypt/live/auth.example.com/privkey.pem
BASE_URL=https://auth.example.comsudo certbot renew --dry-run# /etc/letsencrypt/renewal-hooks/post/reload-authority.sh
#!/bin/bash
docker-compose -f /path/to/docker-compose.yml restart authority# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;# Check certificate
openssl s_client -connect auth.example.com:443 -servername auth.example.com
# SSL Labs test
# Visit: https://www.ssllabs.com/ssltest/analyze.html?d=auth.example.comSSL_CERT=/path/to/fullchain.pem # Not just cert.pemchmod 644 authority.crt
chmod 600 authority.keyBASE_URL=https://auth.example.comPASSWORD_MIN_LENGTH=12
REQUIRE_UPPERCASE=true
REQUIRE_LOWERCASE=true
REQUIRE_NUMBERS=true
REQUIRE_SPECIAL=false
PASSWORD_HISTORY_COUNT=5
PASSWORD_EXPIRY_DAYS=0PASSWORD_MIN_LENGTH=16
REQUIRE_UPPERCASE=true
REQUIRE_LOWERCASE=true
REQUIRE_NUMBERS=true
REQUIRE_SPECIAL=true
PASSWORD_HISTORY_COUNT=12
PASSWORD_EXPIRY_DAYS=90PASSWORD_MIN_LENGTH=10
REQUIRE_UPPERCASE=false
REQUIRE_LOWERCASE=false
REQUIRE_NUMBERS=false
REQUIRE_SPECIAL=false
PASSWORD_HISTORY_COUNT=3
PASSWORD_EXPIRY_DAYS=0Password must:
✗ Be at least 12 characters
✓ Contain an uppercase letter
✓ Contain a lowercase letter
✗ Contain a numberPASSWORD_EXPIRY_WARNING_DAYS=14PASSWORD_EXPIRY_GRACE_DAYS=7# Remember last 5 passwords
PASSWORD_HISTORY_COUNT=5This password was used recently. Please choose a different password.CHECK_COMMON_PASSWORDS=true
COMMON_PASSWORD_LIST=/path/to/passwords.txtwget https://github.com/danielmiessler/SecLists/raw/master/Passwords/Common-Credentials/10k-most-common.txt -O passwords.txtPOST /api/validate-password
Content-Type: application/json
{
"password": "MyNewPassword123"
}{
"valid": false,
"errors": [
"Password must contain a special character"
]
}GET /api/users/{id}/password-status
Authorization: Bearer {token}{
"last_changed": "2024-01-01T00:00:00Z",
"expires_at": "2024-04-01T00:00:00Z",
"days_until_expiry": 45,
"requires_change": false
}function generatePassword(length = 16) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}Name it (e.g., "My App Auth")
Click Create
Enable the OAuth consent screen:
Navigate to APIs & Services > OAuth consent screen
Select External user type
Click Create
Fill in required fields:
App name: Your app name
User support email: Your email
Developer contact: Your email
Click Save and Continue through remaining steps
Create OAuth credentials:
Navigate to APIs & Services > Credentials
Click Create Credentials > OAuth client ID
Application type: Web application
Name: "Authority Integration"
Authorized redirect URIs: http://localhost:4000/auth/google/callback
Click Create
Copy your Client ID and Client Secret
Paste your Client ID
Paste your Client Secret
Click Save
After approval, you'll be redirected back to Authority
Check the admin dashboard - a new user should appear!
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<style>
.btn-google {
display: inline-flex;
align-items: center;
padding: 12px 24px;
background: #4285f4;
color: white;
border-radius: 4px;
text-decoration: none;
font-family: sans-serif;
font-weight: 500;
}
.btn-google:hover {
background: #357abd;
}
.btn-google svg {
margin-right: 12px;
}
</style>
</head>
<body>
<h1>Welcome to My App</h1>
<a href="http://localhost:4000/auth/google" class="btn-google">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z" fill="#4285F4"/>
<path d="M9.003 18c2.43 0 4.467-.806 5.956-2.18l-2.909-2.26c-.806.54-1.836.86-3.047.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009.003 18z" fill="#34A853"/>
<path d="M3.964 10.712A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.33z" fill="#FBBC05"/>
<path d="M9.003 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.464.891 11.428 0 9.002 0A8.997 8.997 0 00.957 4.958L3.964 7.29c.708-2.127 2.692-3.71 5.036-3.71z" fill="#EA4335"/>
</svg>
Sign in with Google
</a>
</body>
</html>// Encode your callback URL
const callbackUrl = 'http://localhost:3000/dashboard';
const encodedUrl = btoa(callbackUrl);
// Build the auth URL with forward_url
const authUrl = `http://localhost:4000/auth/google?forward_url=${encodedUrl}`;
// Use this URL for your button
document.querySelector('.btn-google').href = authUrl;http://localhost:4000/auth/google/callbackaddress
address
phone
phone_number, phone_number_verified
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email', // OpenID Connect scopes
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});openid
sub (required for OIDC)
profile
name, family_name, given_name, picture
email
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}function parseIdToken(idToken) {
const parts = idToken.split('.');
const payload = JSON.parse(atob(parts[1]));
return payload;
}
// Result:
// {
// "iss": "http://localhost:4000",
// "sub": "user-uuid",
// "aud": "your_client_id",
// "exp": 1699999999,
// "iat": 1699996399,
// "name": "John Doe",
// "email": "john@example.com",
// "email_verified": true
// }async function validateIdToken(idToken) {
const payload = parseIdToken(idToken);
// 1. Verify issuer
if (payload.iss !== AUTHORITY_URL) {
throw new Error('Invalid issuer');
}
// 2. Verify audience
if (payload.aud !== CLIENT_ID) {
throw new Error('Invalid audience');
}
// 3. Verify expiration
if (payload.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
// 4. Verify signature (use JWKS)
await verifySignature(idToken);
return payload;
}async function getUserInfo(accessToken) {
const response = await fetch(`${AUTHORITY_URL}/userinfo`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
return response.json();
}
// Result:
// {
// "sub": "user-uuid",
// "name": "John Doe",
// "given_name": "John",
// "family_name": "Doe",
// "email": "john@example.com",
// "email_verified": true,
// "picture": "https://..."
// }class AuthSession {
constructor() {
this.tokens = null;
this.user = null;
}
login(tokens, user) {
this.tokens = tokens;
this.user = user;
// Store refresh token securely (httpOnly cookie recommended)
localStorage.setItem('auth_session', JSON.stringify({
accessToken: tokens.access_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
user: user
}));
}
getAccessToken() {
const session = JSON.parse(localStorage.getItem('auth_session'));
if (!session) return null;
if (Date.now() > session.expiresAt) {
// Token expired - need to refresh
return null;
}
return session.accessToken;
}
getUser() {
const session = JSON.parse(localStorage.getItem('auth_session'));
return session?.user;
}
logout() {
localStorage.removeItem('auth_session');
this.tokens = null;
this.user = null;
}
}
const auth = new AuthSession();<div id="profile" style="display: none;">
<img id="avatar" src="" alt="Profile picture">
<h2 id="name"></h2>
<p id="email"></p>
<button id="logout">Logout</button>
</div>
<script>
function showProfile(user) {
document.getElementById('profile').style.display = 'block';
document.getElementById('avatar').src = user.picture || '/default-avatar.png';
document.getElementById('name').textContent = user.name;
document.getElementById('email').textContent = user.email;
}
document.getElementById('logout').onclick = () => {
auth.logout();
window.location.href = '/';
};
</script>function logout() {
auth.logout();
window.location.href = '/';
}function logoutWithRedirect() {
auth.logout();
const params = new URLSearchParams({
post_logout_redirect_uri: 'http://localhost:3000',
client_id: CLIENT_ID
});
window.location.href = `${AUTHORITY_URL}/logout?${params}`;
}class AuthClient {
constructor(config) {
this.clientId = config.clientId;
this.redirectUri = config.redirectUri;
this.authorityUrl = config.authorityUrl;
this.session = new AuthSession();
}
async login() {
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const state = this.generateCodeVerifier();
sessionStorage.setItem('pkce_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${this.authorityUrl}/authorize?${params}`;
}
async handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state');
}
const codeVerifier = sessionStorage.getItem('pkce_verifier');
const tokens = await this.exchangeCode(code, codeVerifier);
const user = await this.getUserInfo(tokens.access_token);
this.session.login(tokens, user);
// Clean up
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
window.history.replaceState({}, '', window.location.pathname);
return user;
}
async exchangeCode(code, codeVerifier) {
const response = await fetch(`${this.authorityUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: codeVerifier
})
});
return response.json();
}
async getUserInfo(accessToken) {
const response = await fetch(`${this.authorityUrl}/userinfo`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
return response.json();
}
logout() {
this.session.logout();
}
isAuthenticated() {
return this.session.getAccessToken() !== null;
}
getUser() {
return this.session.getUser();
}
// PKCE helpers
generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this.base64URLEncode(array);
}
async generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return this.base64URLEncode(new Uint8Array(hash));
}
base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
}
// Usage
const auth = new AuthClient({
clientId: 'your_client_id',
redirectUri: 'http://localhost:3000/callback',
authorityUrl: 'http://localhost:4000'
});
if (window.location.search.includes('code=')) {
auth.handleCallback().then(user => {
console.log('Logged in as:', user.name);
});
}email, email_verified
Failed attempts before lockout
LOCKOUT_DURATION
30
Lockout duration (minutes)
ENABLE_AUTO_UNLOCK
true
Auto-unlock after duration
# Lock after 5 failed attempts
LOCKOUT_THRESHOLD=5
# Lock for 30 minutes
LOCKOUT_DURATION=30
# Auto-unlock enabled
ENABLE_AUTO_UNLOCK=true# First lockout: 15 minutes
# Second lockout: 30 minutes
# Third lockout: 60 minutes
# Fourth+: 120 minutes
PROGRESSIVE_LOCKOUT=trueuser.login_failed
Failed login attempt
user.locked
Account locked
user.unlocked
# Example: Send alert on multiple lockouts
grep "user.locked" /var/log/authority/audit.log | alert-scriptGET /api/users/{id}
Authorization: Bearer {admin_token}{
"id": "user-uuid",
"email": "user@example.com",
"locked": true,
"locked_at": "2024-01-15T10:30:00Z",
"failed_attempts": 5,
"unlock_at": "2024-01-15T11:00:00Z"
}POST /api/users/{id}/unlock
Authorization: Bearer {admin_token}LOCKOUT_BY_IP=true
IP_LOCKOUT_THRESHOLD=10LOCKOUT_WHITELIST=192.168.1.0/24,10.0.0.0/8Your account has been temporarily locked due to multiple failed login attempts.
Please try again in 30 minutes or contact support.Forgot your password? [Reset Password]
Account unlocked (auto or manual)
User activity tracking
user.login
Successful login
user.login_failed
Failed login attempt
Each log entry includes:
id
Unique log entry ID
timestamp
When the event occurred
event
Example entry:
Navigate to Admin Dashboard → Audit Logs
Use filters to find specific events
Date range - Start and end dates
Event type - Filter by specific event
User - Filter by actor
Resource - Filter by affected resource
IP address - Filter by source IP
Export logs in various formats:
CSV - For spreadsheet analysis
JSON - For processing with scripts
PDF - For reports
Response:
Export logs to security information systems:
Monitor for:
Multiple failed logins from same IP
Admin account logins from new IPs
MFA disabled events
Client secret rotations
Audit logs help demonstrate:
Who accessed user data
What changes were made
When access occurred
Logs provide evidence of:
Access controls
Monitoring activities
Incident response
Enable MFA - Multi-factor authentication
Account Lockout - Brute-force protection
Password Policies - Password requirements
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2024-01-15T10:30:00Z",
"event": "user.login",
"actor_id": "user-uuid",
"actor_type": "user",
"resource_type": "session",
"resource_id": "session-uuid",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"metadata": {
"method": "password",
"mfa_used": true
}
}GET /api/audit-logs?limit=100&offset=0
Authorization: Bearer {admin_token}{
"data": [
{
"id": "...",
"timestamp": "2024-01-15T10:30:00Z",
"event": "user.login",
...
}
],
"total": 1250,
"limit": 100,
"offset": 0
}GET /api/audit-logs?event=user.login_failed&from=2024-01-01&to=2024-01-31
Authorization: Bearer {admin_token}GET /api/audit-logs/{id}
Authorization: Bearer {admin_token}# Retain logs for 90 days
AUDIT_LOG_RETENTION_DAYS=90
# Run cleanup daily
AUDIT_LOG_CLEANUP_SCHEDULE=0 2 * * *crystal run src/tasks/cleanup_audit_logs.crAUDIT_LOG_SYSLOG=true
SYSLOG_HOST=logs.example.com
SYSLOG_PORT=514AUDIT_LOG_FILE=/var/log/authority/audit.log
AUDIT_LOG_FORMAT=json# Splunk
AUDIT_LOG_SPLUNK_URL=https://splunk.example.com:8088
AUDIT_LOG_SPLUNK_TOKEN=your-hec-token
# Elasticsearch
AUDIT_LOG_ELASTICSEARCH_URL=https://elasticsearch.example.com:9200
AUDIT_LOG_ELASTICSEARCH_INDEX=authority-audit# Alert after 10 failed logins in 5 minutes
grep "user.login_failed" /var/log/authority/audit.log | \
awk -v d="$(date -d '5 minutes ago' +%Y-%m-%dT%H:%M)" '$2 > d' | \
wc -l | \
xargs -I {} sh -c '[ {} -gt 10 ] && echo "Alert: High failed logins"'Click OAuth Apps > New OAuth App
Fill in the application details:
Application name: Your app name
Homepage URL: https://your-app.com
Authorization callback URL: https://your-authority-domain/auth/github/callback
Click Register application
On the app page:
Copy the Client ID
Click Generate a new client secret
Copy the Client Secret immediately (shown only once)
Log in to Authority admin dashboard
Navigate to Settings > Social Login
Enable GitHub OAuth
Enter your credentials:
Client ID: Your GitHub client ID
Client Secret: Your GitHub client secret
Save settings
With forward URL:
Click your GitHub sign-in button
Authorize the OAuth app on GitHub
You'll be redirected back to Authority
Account created/linked automatically
Redirected to your application
id
Unique GitHub user ID
login
GitHub username
email
To restrict access to specific organizations, you can check membership after authentication in your application logic.
Client ID or secret is incorrect.
Solution: Regenerate the client secret in GitHub and update Authority settings.
GitHub email is private and user didn't authorize email scope.
Solution: The user:email scope is requested by default. If email is still missing:
User may not have a verified email on GitHub
User may have denied email permission
Callback URL doesn't match GitHub app configuration.
Solution: Ensure exact match:
For GitHub Enterprise Server, contact your administrator about custom OAuth endpoints.
Configure Google - Add Google sign-in
Manage Linked Accounts - Link multiple providers
GITHUB_OAUTH_ENABLED=true
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret<a href="https://your-authority-domain/auth/github" class="btn-github">
<svg><!-- GitHub icon --></svg>
Sign in with GitHub
</a>const forwardUrl = btoa('https://your-app.com/dashboard');
const githubAuthUrl = `https://your-authority-domain/auth/github?forward_url=${forwardUrl}`;https://your-authority-domain/auth/github/callbackGo to Facebook Developers
Click My Apps > Create App
Select app type:
Choose Consumer or Business depending on your use case
Click Next
Fill in app details:
App name: Your application name
App contact email: Your email
Click Create App
Add Facebook Login product:
Find Facebook Login in products
Click Set Up
Choose Web
Enter your site URL
Configure OAuth settings:
Go to Facebook Login > Settings
Add to Valid OAuth Redirect URIs:
https://your-authority-domain/auth/facebook/callbackSave changes
Get credentials:
Go to Settings > Basic
Copy App ID and App Secret
Log in to Authority admin dashboard
Navigate to Settings > Social Login
Enable Facebook OAuth
Enter your credentials:
Client ID: Your Facebook App ID
Client Secret: Your Facebook App Secret
Save settings
With forward URL:
For production use, you need to:
Complete Data Use Checkup - Explain how you use user data
Add Privacy Policy URL - Required for public apps
Submit for App Review - If requesting advanced permissions
For basic login (email + public_profile), you may not need full review.
id
Facebook user ID
email
User's email (if permitted)
name
Your Facebook app is in development mode.
Solution:
In development mode, only app admins/developers/testers can log in
Add test users in Roles section
Or switch to Live mode after review
Redirect URI isn't whitelisted.
Solution: Add the exact callback URL to Valid OAuth Redirect URIs:
User may have signed up with phone number or denied email permission.
Solution: Handle missing email gracefully in your application.
Requested scopes not approved for your app.
Solution:
For email and public_profile, no review needed
Advanced scopes require App Review
Facebook has strict data usage policies:
Only request data you need
Explain data usage in your privacy policy
Delete user data upon request
Complete annual Data Use Checkup
Configure Google - Add Google sign-in
Manage Linked Accounts - Account linking
FACEBOOK_OAUTH_ENABLED=true
FACEBOOK_CLIENT_ID=your-facebook-app-id
FACEBOOK_CLIENT_SECRET=your-facebook-app-secret<a href="https://your-authority-domain/auth/facebook" class="btn-facebook">
<svg><!-- Facebook icon --></svg>
Continue with Facebook
</a>const forwardUrl = btoa('https://your-app.com/dashboard');
const facebookAuthUrl = `https://your-authority-domain/auth/facebook?forward_url=${forwardUrl}`;https://your-authority-domain/auth/facebook/callbackGo to LinkedIn Developers
Click Create App
Fill in app details:
App name: Your application name
LinkedIn Page: Select or create a company page
Privacy policy URL: Your privacy policy
App logo: Upload your logo
Click Create app
In the Auth tab:
Note your Client ID and Client Secret
Add Authorized redirect URLs:
https://your-authority-domain/auth/linkedin/callbackIn the Products tab:
Request access to Sign In with LinkedIn using OpenID Connect
This provides openid, profile, and email scopes
Log in to Authority admin dashboard
Navigate to Settings > Social Login
Enable LinkedIn OAuth
Enter your credentials:
Client ID: Your LinkedIn Client ID
Client Secret: Your LinkedIn Client Secret
Save settings
With forward URL:
sub
LinkedIn member ID
email
User's email address
email_verified
Callback URL not registered in LinkedIn app.
Solution: Add exact URL to Authorized redirect URLs:
Requested scope not approved for your app.
Solution:
Ensure you've added "Sign In with LinkedIn using OpenID Connect" product
Wait for product access approval
Client ID is incorrect or app is deleted.
Solution: Verify Client ID in LinkedIn Developer Console.
Authority uses LinkedIn's OpenID Connect implementation which provides:
Standard OIDC claims
Simplified integration
Better long-term stability
Configure Google - Add more providers
Manage Linked Accounts - Account linking
LINKEDIN_OAUTH_ENABLED=true
LINKEDIN_CLIENT_ID=your-linkedin-client-id
LINKEDIN_CLIENT_SECRET=your-linkedin-client-secret<a href="https://your-authority-domain/auth/linkedin" class="btn-linkedin">
<svg><!-- LinkedIn icon --></svg>
Sign in with LinkedIn
</a>const forwardUrl = btoa('https://your-app.com/dashboard');
const linkedinAuthUrl = `https://your-authority-domain/auth/linkedin?forward_url=${forwardUrl}`;https://your-authority-domain/auth/linkedin/callbackUses JWT-based client authentication (not simple client secret)
Requires Apple Developer Program membership
Has strict UI requirements
Offers email relay for privacy
Go to Apple Developer Console
Navigate to Certificates, Identifiers & Profiles
Click Identifiers > + button
Select App IDs > Continue
Select App type > Continue
Fill in details:
Description: Your app name
Bundle ID: com.yourcompany.yourapp (explicit, not wildcard)
In Capabilities, enable Sign in with Apple
Click Continue > Register
Click Identifiers > + button
Select Services IDs > Continue
Fill in details:
Description: Your service description
Identifier: com.yourcompany.yourapp.auth (this is your Client ID)
Click Continue > Register
Click on your new Services ID to configure:
Enable Sign in with Apple
Click Configure
In configuration:
Primary App ID: Select your App ID from Step 1
Domains: your-authority-domain (without https://)
Return URLs: https://your-authority-domain/auth/apple/callback
Navigate to Keys > + button
Fill in details:
Key Name: Authority Sign-in Key
Enable Sign in with Apple
Click Configure and select your Primary App ID
Click Continue > Register
Download the key file (.p8) - you can only download it once!
Note the Key ID displayed
Find your Team ID:
Go to Membership in Apple Developer Console
Your Team ID is displayed (10-character string)
You need four pieces of information:
Client ID
Services ID identifier (e.g., com.yourcompany.yourapp.auth)
Team ID
Membership page in Developer Console
Key ID
Log in to Authority admin dashboard
Navigate to Settings > Social Login
Enable Apple OAuth
Enter your credentials:
Client ID: Your Services ID identifier
Team ID: Your Apple Team ID
Key ID: Your key identifier
Private Key: Paste contents of .p8 file (including BEGIN/END lines)
Save settings
Apple has specific requirements for their button:
See Apple's Human Interface Guidelines for button requirements.
sub
Unique Apple user ID
email
User's email (real or relay)
email_verified
Users can choose to hide their real email. Apple provides a relay address like:
Emails sent to this address are forwarded to the user's real email.
To send emails to relay addresses:
Register your email domains in Apple Developer Console
Configure SPF/DKIM for your sending domain
Services ID not configured correctly.
Solution:
Verify Services ID identifier matches your Client ID
Ensure Sign in with Apple is enabled on the Services ID
Check domain and return URL configuration
Callback URL not registered.
Solution:
In Services ID configuration, verify Return URL exactly matches:
https://your-authority-domain/auth/apple/callbackEnsure domain is registered (without https://)
Authorization code expired or already used.
Solution: Apple codes expire quickly. Ensure your token exchange happens promptly.
Apple only provides name on first authentication.
Solution: Store the name immediately on first login. If missed, user must revoke app access and re-authenticate.
Protect your private key - Store securely, never in code
Rotate keys periodically - Create new key, update config, then delete old key
Handle relay emails - Test email delivery to relay addresses
First-auth data - Cache name immediately as it's only provided once
Configure Google - Simpler provider setup
Manage Linked Accounts - Account linking
APPLE_OAUTH_ENABLED=true
APPLE_CLIENT_ID=com.yourcompany.yourapp.auth
APPLE_TEAM_ID=XXXXXXXXXX
APPLE_KEY_ID=XXXXXXXXXX
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIGTAgEA...
-----END PRIVATE KEY-----"<a href="https://your-authority-domain/auth/apple" class="btn-apple">
<svg><!-- Apple logo --></svg>
Sign in with Apple
</a>abc123@privaterelay.appleid.comNew user: Account created automatically with social provider linked
Existing user (same email): Social provider linked to existing account
Signed-in user: Social provider added to their account
For signed-in users to add a social provider:
The user will:
Authenticate with the social provider
Return to Authority with provider linked
Continue with their existing session
Request:
Response (Success):
Response (Error):
Authority prevents unlinking when:
It's the user's only login method
User has no password set
Would leave account inaccessible
Solution: User must set a password or link another provider first.
Show users their linked accounts:
When linking accounts, Authority verifies:
Email match: Social account email matches existing account
Session valid: User is properly authenticated
State parameter: CSRF protection via state validation
If a social account is already linked to a different user:
The user must unlink from the other account first.
All link/unlink operations are recorded:
For each linked social account:
provider
Provider name (google, github, etc.)
provider_user_id
Unique ID from provider
email
Show linked status - Display which providers are connected
Confirm unlinking - Require user confirmation
Explain consequences - Warn if unlinking removes login method
Update UI immediately - Reflect changes without page reload
Log actions - Track for security auditing
Configure Google - Set up providers
Enable MFA - Additional security
Audit Logging - Track account changes
<a href="https://your-authority-domain/auth/google?link=true">
Connect Google Account
</a>POST /auth/{provider}/unlinkcurl -X POST https://your-authority-domain/auth/google/unlink \
-H "Cookie: session=..." \
-H "Content-Type: application/json"{
"success": true,
"message": "Google account unlinked successfully"
}{
"error": "cannot_unlink",
"message": "Cannot unlink the only login method. Set a password first."
}// Fetch user's linked providers
const response = await fetch('https://your-authority-domain/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const userInfo = await response.json();
// Check for linked providers in user profile<div class="linked-accounts">
<h3>Linked Accounts</h3>
<div class="provider">
<span class="icon">🔵</span>
<span class="name">Google</span>
<span class="email">user@gmail.com</span>
<button onclick="unlinkProvider('google')">Disconnect</button>
</div>
<div class="provider">
<span class="icon">⚫</span>
<span class="name">GitHub</span>
<span class="status">Not connected</span>
<a href="/auth/github?link=true">Connect</a>
</div>
</div>async function unlinkProvider(provider) {
if (!confirm(`Disconnect ${provider}?`)) return;
try {
const response = await fetch(`/auth/${provider}/unlink`, {
method: 'POST',
credentials: 'include'
});
const result = await response.json();
if (result.success) {
// Refresh UI
location.reload();
} else {
alert(result.message);
}
} catch (error) {
alert('Failed to unlink account');
}
}{
"error": "already_linked",
"message": "This social account is linked to another user"
}{
"event": "social_account_linked",
"user_id": "user-uuid",
"provider": "google",
"provider_user_id": "google-user-id",
"timestamp": "2024-01-15T10:30:00Z"
}
{
"event": "social_account_unlinked",
"user_id": "user-uuid",
"provider": "github",
"timestamp": "2024-01-15T11:45:00Z"
}Scopes - Permitted access levels
Confidential
Can securely store secrets
Server-side apps
Public
Cannot store secrets
Navigate to Admin Dashboard → OAuth Clients
Click New Client
Fill in the form:
Name
Display name for the client
Type
Confidential or Public
Redirect URIs
Click Create
After creation, you'll receive:
Client ID: abc123def456...
Client Secret: xyz789ghi012... (save this - shown only once)
Response:
Use exact URLs
Use wildcards
Use HTTPS in production
Use HTTP in production
Register all environments
Configure which OAuth flows the client can use:
Authorization Code
authorization_code
Web apps
PKCE
authorization_code
client_secret_basic
HTTP Basic auth
client_secret_post
Secret in body
none
Assign allowed scopes:
Clients can only request scopes they're allowed to use.
Store additional client information:
After registration, test the authorization flow:
Configure Scopes - Create custom scopes
Rotate Secrets - Secret management
First OAuth Integration - Complete tutorial
POST /register
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"client_name": "My Application",
"redirect_uris": [
"https://myapp.com/callback",
"https://myapp.com/auth/callback"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"response_types": ["code"],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic"
}{
"client_id": "abc123def456",
"client_secret": "xyz789ghi012",
"client_name": "My Application",
"redirect_uris": [
"https://myapp.com/callback",
"https://myapp.com/auth/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic",
"client_id_issued_at": 1705312200,
"client_secret_expires_at": 0
}GET /register/{client_id}
Authorization: Bearer {admin_token}PATCH /register/{client_id}
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"redirect_uris": [
"https://myapp.com/callback",
"https://myapp.com/auth/callback",
"https://staging.myapp.com/callback"
]
}DELETE /register/{client_id}
Authorization: Bearer {admin_token}https://myapp.com/callback
https://myapp.com/auth/callback
https://staging.myapp.com/callback
http://localhost:3000/callback (development only)https://myapp.com/* # No wildcards
https://*.myapp.com/callback # No wildcards
http://myapp.com/callback # No HTTP in production{
"scope": "openid profile email read write admin"
}{
"client_name": "My Application",
"client_uri": "https://myapp.com",
"logo_uri": "https://myapp.com/logo.png",
"tos_uri": "https://myapp.com/terms",
"policy_uri": "https://myapp.com/privacy",
"contacts": ["support@myapp.com"]
}# Open in browser
open "http://localhost:4000/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code&scope=openid"openid
OpenID Connect
sub
profile
User profile
name, family_name, given_name, picture
Navigate to Admin Dashboard → Scopes
Click New Scope
Fill in:
Name
Scope identifier (e.g., read)
Description
Human-readable description
Default
Click Create
Select the scope
Modify fields
Click Save
Select the scope
Click Delete
Confirm deletion
Response:
Use colons to create hierarchies:
Format: resource:action
orders:read
Orders
Read
orders:create
Orders
Include version if needed:
When registering or updating a client:
When users authorize a client, they see requested scopes:
Authority validates that:
Requested scopes exist
Client is allowed to request them
User consents to them
Check scopes in access tokens:
Set default scopes included in all authorizations:
Or mark scopes as default in the admin dashboard.
Define scopes that require other scopes:
When admin is requested, read and write are automatically included.
Register Client - Create OAuth clients
Rotate Secrets - Secret management
Protect Your API - Scope enforcement
POST /api/scopes
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"name": "documents:read",
"description": "Read access to documents",
"default": false
}GET /api/scopes
Authorization: Bearer {admin_token}{
"data": [
{
"id": "scope-uuid",
"name": "documents:read",
"description": "Read access to documents",
"default": false
}
]
}PATCH /api/scopes/{id}
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"description": "Updated description"
}DELETE /api/scopes/{id}
Authorization: Bearer {admin_token}documents:read
documents:write
documents:delete
users:read
users:writeapi:v1:read
api:v2:read{
"client_name": "My App",
"scope": "openid profile email documents:read documents:write"
}My App is requesting access to:
✓ Your profile information
✓ Your email address
✓ Read your documents
✓ Modify your documents
[Allow] [Deny]const token = jwt.verify(accessToken, publicKey);
const scopes = token.scope.split(' ');
if (!scopes.includes('documents:read')) {
throw new ForbiddenError('Insufficient scope');
}DEFAULT_SCOPES=openid profile email{
"name": "admin",
"requires": ["read", "write"]
}Configure LinkedIn - Enable LinkedIn Sign-In
Configure Apple - Enable Sign in with Apple
Manage Linked Accounts - Link/unlink social accounts
Social login (also called social sign-in or OAuth federation) allows users to authenticate using their existing accounts from popular identity providers. This provides:
Reduced friction - Users don't need to create new passwords
Improved security - Leverage provider's security features
Verified emails - Many providers verify user emails
Profile data - Access user profile information
email, profile, openid
Email verification, profile picture
GitHub
user:email, read:user
Create OAuth app with your chosen provider
Configure credentials in Authority admin dashboard
Enable provider in settings
Add login button to your application
See individual provider guides for detailed setup instructions.
Always use HTTPS in production
Store client secrets securely
Validate state parameter to prevent CSRF
Consider requiring email verification
Implement account linking carefully to prevent account takeover
Configure Google - Most common provider
Manage Linked Accounts - Account linking features
Go to Google Cloud Console
Create a new project or select existing one
Navigate to APIs & Services > Credentials
Click Create Credentials > OAuth client ID
If prompted, configure the OAuth consent screen:
Choose External for public apps or Internal for organization-only
Fill in app name, user support email, and developer contact
Add scopes: email, profile, openid
Add test users if in testing mode
For OAuth client ID:
Application type: Web application
Name: Your app name
Authorized redirect URIs: https://your-authority-domain/auth/google/callback
Save your Client ID and Client Secret
Log in to Authority admin dashboard
Navigate to Settings > Social Login
Enable Google OAuth
Enter your credentials:
Client ID: your-google-client-id.apps.googleusercontent.com
Client Secret: your-google-client-secret
Save settings
Add a Google sign-in button to your application:
With forward URL (redirect after login):
Click your Google sign-in button
You should be redirected to Google's consent page
After approving, you'll be redirected back to Authority
A new user account is created (or existing account linked)
You're redirected to your application
Authority fetches the following from Google:
sub
Unique Google user ID
email
User's email address
email_verified
The callback URL doesn't match what's configured in Google Console.
Solution: Ensure the redirect URI in Google Console exactly matches:
Your app is in testing mode and the user isn't a test user.
Solution: Either:
Add the user as a test user in Google Console
Submit your app for verification (production)
The client ID or secret is incorrect.
Solution:
Verify credentials in Authority settings
Check for extra spaces or characters
Regenerate secret if needed
Check Authority logs for errors. Common issues:
Email already exists with different provider
Database connection issues
Missing required scopes
Verify emails - Google provides email_verified claim
Use HTTPS - Required for OAuth callbacks
Restrict domains - In Google Console, you can restrict to your domain
Review permissions - Only request scopes you need
Monitor usage - Check Google Console for suspicious activity
Configure GitHub - Add another provider
Manage Linked Accounts - Account linking
Enable MFA - Add extra security
GOOGLE_OAUTH_ENABLED=true
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret<a href="https://your-authority-domain/auth/google" class="btn-google">
<svg><!-- Google icon --></svg>
Sign in with Google
</a><a href="https://your-authority-domain/auth/google?forward_url=BASE64_ENCODED_URL">
Sign in with Google
</a>const forwardUrl = btoa('https://your-app.com/dashboard');
const googleAuthUrl = `https://your-authority-domain/auth/google?forward_url=${forwardUrl}`;https://your-authority-domain/auth/google/callbackabc12-def34
ghi56-jkl78
mno90-pqr12
...REQUIRE_ADMIN_MFA=trueREQUIRE_MFA=trueMFA_GRACE_PERIOD_DAYS=7GET /api/users/{id}
Authorization: Bearer {admin_token}{
"id": "user-uuid",
"email": "user@example.com",
"mfa_enabled": true,
"mfa_configured_at": "2024-01-15T10:30:00Z"
}// After successful password authentication
if (user.mfa_enabled) {
// Show TOTP input form
showMFAPrompt();
} else if (settings.require_mfa) {
// Redirect to MFA setup
redirectToMFASetup();
}
Manage and rotate OAuth client secrets for security.
Secret may have been exposed
Regular security policy compliance
Employee departure
Security audit requirement
Navigate to Admin Dashboard → OAuth Clients
Select the client
Click Rotate Secret
Copy the new secret immediately
Response:
For production applications, use a two-step rotation:
Some systems support multiple active secrets. If Authority supports this:
Update your application to use the new secret.
If you must rotate immediately:
Have the new secret ready to deploy.
Update your application within seconds:
Create a rotation script:
Configure secrets to expire automatically:
Monitor expiring secrets:
All secret rotations are logged:
Query the audit log:
Set up alerts for secret events:
If you lose a client secret:
Log into admin dashboard
Rotate to generate new secret
Update application immediately
If a secret is compromised:
Rotate immediately
Review audit logs for unauthorized access
Revoke any suspicious tokens
Update application with new secret
- Create OAuth clients
- Access control
- Track rotations
Create and manage administrator accounts in Authority.
Administrators can:
Manage OAuth clients
Create and edit users
Configure scopes
View audit logs
Modify system settings
Navigate to Admin Dashboard → Users
Click New User
Fill in the form:
Click Create
Navigate to Users
Select the user
Click Edit
Change Role to Administrator
Create admin during initial setup:
Require MFA for all admin accounts:
Enforce stronger passwords for admins:
Limit admin access by IP:
Admin actions are logged:
- Session management
- Reset passwords
- Secure admin accounts
View and revoke user sessions in Authority.
Sessions represent active user logins. Each session tracks:
Device information
IP address
Login time
Last activity
Users can see their sessions in the profile:
Click profile name
Select Security Settings
View Active Sessions
Find the session in the list
Click Revoke
Confirm revocation
The session is immediately invalidated.
Response:
This forces the user to re-authenticate on all devices.
Force users to have only one active session:
When enabled, logging in from a new device revokes existing sessions.
End sessions after inactivity:
Users are logged out after 30 minutes of inactivity.
Each session captures:
Email users about new logins:
Email content:
Alert on unusual patterns:
Login from new location
Multiple simultaneous sessions
Login outside business hours
For security incidents:
Revoking a session can optionally revoke associated tokens:
For single sign-on, a single session can authorize multiple clients.
- Admin accounts
- Reset passwords
- Track sessions
Configure and manage password reset flows in Authority.
User clicks Forgot Password on login page
Enters email address
Receives reset link via email
Clicks link and sets new password
Logs in with new password
Users visit /forgot-password and enter their email.
Authority sends an email with a secure reset link:
Customize the reset email in public/templates/emails/password-reset.html:
Navigate to Admin Dashboard → Users
Select the user
Click Reset Password
Choose:
Prevent abuse of reset endpoint:
This allows 3 reset requests per hour per email.
Reset tokens are:
Single-use
Time-limited
Cryptographically random
Invalidated on password change
Don't reveal if email exists:
Response is always:
Password reset events are logged:
Customize public/templates/reset-password.html:
After successful reset, show:
Check spam folder
Verify email configuration
Check logs for sending errors
Confirm email address is correct
Request new reset link
Check PASSWORD_RESET_TTL setting
Verify password meets policy requirements
Check password history restrictions
For custom applications:
- Admin accounts
- Session management
- Password requirements
Modify Authority's appearance with Jinja templates.
Authority uses Crinja (Jinja2-compatible) templates for all pages:
Login / Registration
OAuth consent
Password reset
Admin dashboard
User profile
Base layout (layout.html):
Page template (signin.html):
Create a custom theme:
In development, templates reload automatically. In production:
- Customize emails
- Logo and colors
- Full reference
Configure transactional emails sent by Authority.
Authority sends these transactional emails:
Welcome
New user registration
emails/base.html:
emails/password-reset.html:
emails/welcome.html:
Configure default locale:
Check SMTP configuration
Verify credentials
Check spam filters
Review server logs
Use inline CSS
Test with email preview tools
Check image URLs are absolute
Use absolute URLs
Host images on accessible server
Check HTTPS requirements
- Customize pages
- Logo and colors
- Email settings
Customize Authority's visual identity for your organization.
Place your logo in the public directory:
public/images/logo.png
public/images/logo-dark.png # For dark backgrounds
public/images/favicon.icoLogo
200x50px
Customize in public/css/styles.css:
Show client logos on consent page:
- Template customization
- Email branding
- HTTPS setup
Complete reference for all Authority API endpoints.
GET /authorize
Initiates the OAuth 2.0 authorization flow. Displays consent form to user.
Query Parameters:
Example:
Response: Redirects to login page, then to redirect_uri with code and state parameters.
Error Response: Redirects to redirect_uri with error, error_description, and state parameters.
POST /authorize
Process user consent for authorization request.
Request Body (Form-encoded):
Response: 302 redirect to redirect_uri?code=<code>&state=<state>
POST /token
Exchange authorization code, refresh token, or client credentials for access tokens.
Headers:
Request Body (Authorization Code):
Request Body (Refresh Token):
Request Body (Client Credentials):
Request Body (Password Grant - Legacy):
Response: 201 Created
POST /oauth/introspect
Validate a token and get its metadata (RFC 7662).
Headers:
Request Body:
Response (Active Token): 200 OK
Response (Inactive Token):
POST /oauth/revoke
Revoke an access or refresh token (RFC 7009).
Headers:
Request Body:
Response: 200 OK (always succeeds per RFC 7009)
POST /device/code
Start device authorization flow (RFC 8628).
Headers:
Request Body:
Response: 201 Created
POST /device/token
Poll for device authorization completion.
Request Body:
Response (Pending): 400 Bad Request
Response (Success): 201 Created
GET /activate
Display device activation form for user to enter code.
Query Parameters:
Response: HTML form for user to enter device code.
POST /activate
Process device activation.
Request Body:
Response: HTML confirmation page.
GET /oauth2/userinfo
Get authenticated user's claims.
Headers:
Response: 200 OK
GET /.well-known/openid-configuration
OpenID Connect discovery document.
Response: 200 OK
Caching: public, max-age=3600
GET /.well-known/jwks.json
JSON Web Key Set for token verification.
Response: 200 OK
Caching: public, max-age=3600
POST /register
Dynamically register an OAuth client (RFC 7591).
Request Body:
Response: 201 Created
Validation:
redirect_uris must use HTTPS (except localhost for development)
redirect_uris must not contain URL fragments
GET /signin
Display sign-in form.
Query Parameters:
Response: HTML sign-in form
POST /signin
Authenticate user.
Request Body (Form-encoded):
Response (Success): 302 redirect to profile or forward_url
Response (MFA Required): 302 redirect to /mfa/verify
Response (Account Locked): 423 Locked with Retry-After header
Response (Invalid Credentials): 401 Unauthorized
POST /signout
End user session.
Response: 302 redirect to sign-in page
GET /forgot-password
Display forgot password form.
POST /forgot-password
Request password reset email.
Request Body (Form-encoded):
Response: Always shows success (prevents email enumeration)
POST /account/password/reset
Request password reset token.
Request Body:
Response: 200 OK (always, to prevent enumeration)
POST /account/password/confirm
Complete password reset.
Request Body:
Response (Success): 200 OK
Response (Error): 400 Bad Request
POST /account/email/verify
Verify email address.
Request Body:
Response: 200 OK or 400 Bad Request
POST /account/email/resend
Resend verification email.
Request Body:
Response: 200 OK
GET /mfa/setup
Display MFA setup with QR code.
Response: HTML page with:
QR code for authenticator app
Secret key (manual entry)
Backup codes
Authentication Required: Yes (session)
POST /mfa/enable
Enable MFA for account.
Request Body:
Response: 302 redirect
Authentication Required: Yes (session)
POST /mfa/verify
Verify MFA code during login.
Request Body:
Response: 302 redirect to profile
POST /mfa/disable
Disable MFA for account.
Request Body:
Response: 302 redirect
Authentication Required: Yes (session)
GET /profile
Display user profile page.
Response: HTML profile page with:
User details
Email verification status
MFA status
Connected social accounts
Authentication Required: Yes (session)
GET /health_check
Check server health.
Response: 200 OK
See for social authentication endpoints.
- Social authentication endpoints
- Error response reference
- Rate limiting details
- Grant type specifications
API endpoints for social authentication (OAuth federation).
GET
/auth/{provider}
Initiate social login
Start the OAuth flow with a social provider.
Redirects to the provider's authorization page.
Handles the callback from the social provider after user authorization.
On success, redirects to:
The forward_url if provided during initiation
The default post-login page otherwise
Sets session cookie for authenticated user.
Remove a linked social account from the authenticated user.
Requires active session (session cookie).
Success: 302 redirect to /profile with success flash message.
Error: 302 redirect to /profile with error flash message.
Each provider returns different user information.
The state parameter provides CSRF protection:
Authority generates a random state value
State is stored server-side with expiration
State is included in the authorization URL
Provider returns the state in the callback
State tokens expire after 10 minutes.
After successful authentication:
User record created or updated
Social connection record created/updated
Session created for user
Session cookie set in response
Session cookie attributes:
HttpOnly: Yes
Secure: Yes (in production)
SameSite: Lax
Social login is configured via settings:
- Setup guide
- Account linking
- All API endpoints
Reference for Authority API error responses.
All errors follow this format:
{
"error": "error_code",
"error_description": "Human-readable description",
"error_uri": "https://docs.example.com/errors/error_code"
}invalid_request
Missing or invalid parameter
Example:
Example:
Example:
Example:
Example with Details:
Example:
Check the error_description for details
Verify all required parameters are included
Ensure client credentials are correct
Check token expiration
- Rate limiting details
- Endpoint reference
- Grant specifications
Authority implements rate limiting to protect against abuse.
/authorize
60
1 minute
Rate limit information is included in response headers:
When rate limit is exceeded:
Default rate limiting is per IP address:
Rate limit by OAuth client:
Rate limit by authenticated user:
Exclude IPs from rate limiting:
Configure different limits per client:
Check headers - Monitor X-RateLimit-Remaining
Implement backoff - Use exponential backoff on 429
Cache tokens - Reduce token requests
Batch requests
Monitor rate limit metrics:
Rate of 429 responses
Clients hitting limits frequently
Unusual traffic patterns
For distributed deployments, use Redis:
This ensures consistent rate limiting across multiple Authority instances.
- Endpoint reference
- Error handling
- Redis setup
Complete reference for OAuth 2.0 grant types supported by Authority.
Web apps with backend
Use Authorization Code with a backend:
Use Authorization Code + PKCE:
Use Client Credentials:
Use Device Code:
Authority implements these RFCs:
- OAuth 2.0 Framework
- Bearer Token Usage
- PKCE
- Token Introspection
- Standard web app flow
- Mobile/SPA flow
- Machine-to-machine
- IoT and CLI devices
The authorization code grant is used when an application exchanges an authorization code for an access token.
This flow is for web applications with a server-side component that can securely store the client secret.
GET /authorize
Redirect the user to the authorization endpoint.
User is redirected to redirect_uri with:
POST /token
Exchange the authorization code for tokens.
Always validate state - Prevents CSRF attacks
Use HTTPS - Never transmit tokens over HTTP
Store secrets securely - Never expose client_secret
Validate redirect_uri - Use exact match
- For public clients
- Token renewal
- Response format
PKCE (Proof Key for Code Exchange) extends the authorization code flow for public clients that cannot securely store a client secret.
PKCE protects against authorization code interception attacks by using a dynamically generated secret.
Mobile applications
Single-page applications (SPAs)
Desktop applications
Any client that cannot securely store secrets
GET /authorize
POST /token
PKCE prevents:
Authorization code interception - Attacker cannot use stolen code without verifier
Redirect hijacking - Code is useless without the original verifier
Man-in-the-middle attacks - Verifier is never transmitted before token exchange
- Confidential clients
- Token renewal
- Response format
The client credentials grant is used for machine-to-machine authentication where no user is involved.
This flow is for server-side applications that need to access resources on their own behalf, not on behalf of a user.
Background jobs
Microservice communication
API integrations
Scheduled tasks
System maintenance scripts
POST /token
Since client credentials tokens have no refresh token, cache them:
Clients can only request scopes they're authorized for:
- Token renewal
- Response format
- Secret management
The device authorization flow enables OAuth on devices that have limited input capabilities, such as smart TVs, IoT devices, and CLIs.
This flow allows devices without a browser to authenticate users by directing them to complete authorization on a secondary device (phone or computer).
Smart TVs
Media consoles
Picture frames
Printers
CLI applications
IoT devices
POST /device
Show the user:
Or display a QR code linking to verification_uri_complete.
POST /token
Poll the token endpoint while waiting for user authorization.
Authorization Pending:
Slow Down (polling too fast):
Success:
Access Denied:
Expired Token:
The activation page shows:
After entering the code, users see the standard consent screen.
- Response format
- Token renewal
- Error handling
Refresh tokens allow clients to obtain new access tokens without user interaction.
When an access token expires, use the refresh token to get a new one without requiring the user to re-authenticate.
When you receive tokens, note the expiration:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 3600
}access_token
Short-lived token for API access
POST /token
With rotation enabled, each refresh:
Issues a new access token
Issues a new refresh token
Invalidates the old refresh token
This limits damage if a refresh token is compromised.
Refresh before expiration:
Refresh when access token is rejected:
When this happens, the user must re-authenticate.
Request fewer scopes when refreshing:
You cannot request more scopes than originally granted.
To receive refresh tokens, request the offline_access scope:
Revoke refresh tokens when:
User logs out
User changes password
Security concern detected
- Response format
- Error handling
- Conceptual overview
Reference for OAuth 2.0 token endpoint responses.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}access_token
String
The access token
Authority issues JWTs as access tokens:
Header:
Payload:
No refresh token or ID token for client credentials.
New refresh token issued (rotation).
Use the access token in API requests:
See for all error types.
Parse the JWT
Verify signature using JWKS
Check issuer matches Authority URL
Check audience matches your client ID
For opaque tokens or real-time validation:
Response:
HTTP headers prevent caching:
Never cache token responses.
- Token renewal
- Error handling
- Conceptual overview
The implicit grant returns tokens directly in the URL fragment. It was designed for browser-based applications before PKCE existed.
Tokens exposed in URL - Visible in browser history and logs
No refresh tokens - Users must re-authenticate frequently
Vulnerable to interception - No protection against token leakage
The password grant allows applications to exchange user credentials directly for tokens. This bypasses the normal authorization flow.
Acceptable:
First-party mobile apps (owned by same company)
Migration from legacy systems
Trusted internal tools
OpenID Connect (OIDC) adds identity layer on top of OAuth 2.0, providing user authentication.
While OAuth 2.0 provides authorization, OpenID Connect adds:
Authentication - Verify user identity
ID Tokens - User information in JWT format
UserInfo Endpoint - Fetch additional user claims
OpenID Connect Discovery allows clients to automatically configure themselves.
GET /.well-known/openid-configuration
Returns a JSON document with all provider configuration.
The JWKS endpoint provides public keys for verifying JWT signatures.
GET /.well-known/jwks.json
Returns the public keys used to sign tokens.
The UserInfo endpoint returns claims about the authenticated user.
GET /userinfo POST /userinfo
Both methods are supported.
Claims returned depend on requested scopes:
ID tokens are JWTs that contain claims about the authenticated user.
ID tokens provide proof of authentication. Unlike access tokens (which authorize API access), ID tokens answer "who is this user?"
ID tokens are JWTs with three parts:
Complete reference for all Authority configuration options.
Configuration options for Authority security features.
Configuration options for OAuth 2.0 tokens.
OAuth 2.0 and OpenID Connect terminology.
The entity capable of granting access to a protected resource. When the resource owner is a person, it is referred to as an end-user.
The server hosting the protected resources. Also known as the API server. It accepts and responds to protected resource requests using access tokens.
An application making protected resource requests on behalf of the resource owner. The term "client" does not imply any particular implementation (server, desktop, mobile, etc.).
The server that issues access tokens to the client after authenticating the resource owner and obtaining authorization. Authority is an authorization server.
A credential used to access protected resources. Access tokens represent the authorization granted to the client. Authority issues JWTs as access tokens.
A credential used to obtain new access tokens. Refresh tokens are issued with access tokens and allow clients to get new tokens without user interaction.
A JSON Web Token (JWT) that contains claims about the authentication event and the user. Part of OpenID Connect.
This section explains the concepts, architecture, and design decisions behind Authority.
- Fundamentals of the OAuth 2.0 protocol
- Identity layer on top of OAuth 2.0
- Decision guide for selecting the right flow
Understanding how Authority is built and organized.
The endpoints layer handles HTTP requests and responses:
OAuth Endpoints - Authorization, token, introspection, revocation
OIDC Endpoints - UserInfo, discovery, JWKS
Admin Endpoints - Client, user, scope management
Understanding the fundamentals of OAuth 2.0.
OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user resources without exposing credentials.
Without OAuth, applications would need:
Direct access to user credentials
Full access to all resources
Understanding identity and authentication with OpenID Connect.
Understanding Authority's security architecture and design principles.
Authority implements multiple security layers:
Passwords are:
Hashed - Using bcrypt with cost factor 12
Salted - Unique salt per password
Understanding how tokens are created, used, and retired in Authority.
Short-lived code exchanged for tokens. Separates user authentication from token issuance.
One-time use - Cannot be reused
Short-lived - Minimizes interception window
Client-bound - Tied to specific client
A guide to selecting the right OAuth 2.0 grant type for your application.
Understanding the benefits and use cases for Authority.
Authority is a production-ready OAuth 2.0 Server and OpenID Connect 1.0 Provider built with Crystal. It provides:
Complete OAuth 2.0 implementation
OpenID Connect identity layer
Enterprise security features
Primary email (if public or authorized)
name
Display name
avatar_url
Profile picture URL
Full name
picture
Profile picture URL
Email verification status
name
Full name
given_name
First name
family_name
Last name
picture
Profile picture URL
Click Save
Shown when creating the key
Private Key
Contents of the .p8 file
Always true for Apple
name
Name (only on first auth)
Email from provider
name
Name from provider
avatar_url
Profile picture URL
access_token
Provider access token (encrypted)
refresh_token
Provider refresh token (encrypted)
token_expires_at
Token expiration time
Whether email is verified
name
Full name
given_name
First name
family_name
Last name
picture
Profile picture URL
Regularly review logs for anomalies
user.logout
User logout
user.created
New user registration
user.updated
Profile update
user.deleted
User deletion
user.locked
Account locked
user.unlocked
Account unlocked
mfa.enabled
MFA enabled
mfa.disabled
MFA disabled
password.changed
Password change
password.reset
Password reset request
token.issued
Token issued
token.revoked
Token revocation
client.created
OAuth client created
client.updated
OAuth client updated
client.deleted
OAuth client deleted
scope.created
Scope created
scope.updated
Scope updated
authorization.granted
User granted authorization
authorization.denied
User denied authorization
Event type
actor_id
User or client who performed action
actor_type
user, client, or system
resource_type
Type of resource affected
resource_id
ID of affected resource
ip_address
Client IP address
user_agent
Browser/client information
metadata
Additional event-specific data

Mobile apps, SPAs
Callback URLs (one per line)
Scopes
Allowed scopes
Grant Types
Enabled OAuth flows
Use localhost in production
Mobile/SPA
Client Credentials
client_credentials
Service-to-service
Refresh Token
refresh_token
Token renewal
Device Code
urn:ietf:params:oauth:grant-type:device_code
IoT/CLI
Public client (no secret)

Use hierarchical naming
Include scopes in API documentation
Changing scope meaning after deployment
email
Email address
email, email_verified
address
Postal address
address
phone
Phone number
phone_number, phone_number_verified
offline_access
Refresh tokens
-
Include in all authorizations
Create
products:list
Products
List

A short-lived code returned after user authorization. Exchanged for access tokens at the token endpoint.
A code used in the device authorization flow. Displayed to users who authorize on a separate device.
The method used to obtain an access token. Common types:
Authorization Code - Web applications
Client Credentials - Machine-to-machine
Device Code - IoT devices, CLIs
Refresh Token - Token renewal
A string that defines the access level requested by the client. Examples: read, write, openid, profile.
The URL where the authorization server redirects after authorization. Also called callback URL.
A random value used to prevent CSRF attacks during the authorization flow.
An extension that protects authorization code flow against interception. Pronounced "pixie".
A random string used in PKCE. Created by the client before authorization.
A transformation of the code verifier. Sent in the authorization request and verified during token exchange.
A client capable of maintaining the confidentiality of its credentials. Typically server-side applications.
A client that cannot maintain secret credentials. Mobile apps, SPAs, and native applications.
A client owned by the same entity that operates the authorization server. Trusted with user credentials.
A client owned by a different entity. Should never directly handle user credentials.
Pieces of information about a user. Examples: name, email, sub.
An endpoint that returns claims about the authenticated user.
The mechanism to automatically find authorization server endpoints and capabilities.
A set of public keys used to verify token signatures.
A random value sent in the authorization request and included in the ID token to prevent replay attacks.
Authentication requiring multiple verification methods. Authority supports TOTP-based MFA.
A temporary passcode generated by an authenticator app. Changes every 30 seconds.
A server-side record of a user's authentication state. Persists across requests.
A mechanism for resource servers to query the authorization server about a token's current state.
The process of invalidating a token before its natural expiration.
The user's explicit approval of the scopes requested by a client.
A record of security-relevant events for compliance and monitoring.
Temporarily blocking access after multiple failed authentication attempts.
The first part of a JWT containing the token type and signing algorithm.
The second part of a JWT containing claims about the subject.
The third part of a JWT used to verify the token hasn't been tampered with.
The entity that issued the token. For Authority tokens, this is the server URL.
The identifier of the principal (user) the token is about.
The intended recipient of the token. Typically the client ID.
The timestamp after which the token should not be accepted.
The OAuth 2.0 Authorization Framework specification.
OAuth 2.0 Bearer Token Usage specification.
Proof Key for Code Exchange (PKCE) specification.
Device Authorization Grant specification.
The specification for identity layer on top of OAuth 2.0.
Architecture - How Authority is built and organized
Security Model - Security architecture and design principles
Token Lifecycle - How tokens are issued, used, and revoked
Why Authority? - Benefits and use cases
Unlike tutorials (which teach by doing) and how-to guides (which solve specific problems), explanations provide understanding. Read these when you want to:
Understand why things work the way they do
Learn the theory behind the implementation
Make informed architectural decisions
Evaluate trade-offs between approaches
After understanding the concepts, explore:
Tutorials - Learn by building
How-To Guides - Solve specific problems
Reference - Technical specifications
Update your application configuration
The old secret is immediately invalidated
client.secret_rotated
Secret was rotated
client.secret_expired
Secret expired
Role
Select Administrator
Click Save
client.created
Client created by admin
settings.changed
Settings modified
Admin email address
Name
Display name
Password
user
Profile management, OAuth authorizations
admin
Full system access
super_admin
admin.login
Admin login
admin.created
New admin created
admin.role_changed

Initial password
Can create other admins
Role modified
SINGLE_SESSION
false
Allow only one active session
location
Approximate location (if enabled)
created_at
Login timestamp
last_activity
Last request timestamp
session.expired
Session timed out
SESSION_DURATION_DAYS
7
Maximum session lifetime
IDLE_TIMEOUT_MINUTES
30
ip_address
Client IP at login
user_agent
Browser/app information
device
session.created
New login
session.refreshed
Session activity
session.revoked

Timeout after inactivity
Parsed device type
Session ended
Send Reset Email - User receives email
Set Password - Admin sets new password
password.reset_failed
Reset attempt failed
PASSWORD_RESET_TTL
3600
Reset token lifetime (seconds)
PASSWORD_RESET_EMAIL_SUBJECT
Reset your password
password.reset_requested
Reset email requested
password.reset_sent
Reset email sent
password.reset_completed
Email subject
Password was reset
user.name
User's name
user.email
User's email
login_time
Login timestamp
Email Verification
Verify email address
Password Reset
Reset password request
MFA Enabled
Two-factor authentication enabled
New Login
Login from new device
base_url
Authority base URL
app_name
Application name
company_address
reset_url
Password reset link
expiry_minutes
Link expiry time
verification_url
Verification link
expiry_hours
Link expiry time
device
Device description
location
Approximate location
ip_address
Company address
Client IP
PNG, SVG
Logo (dark)
200x50px
PNG, SVG
Favicon
32x32px
ICO, PNG
Apple Touch
180x180px
PNG
Yes
Space-separated scopes
state
Yes
CSRF protection token
code_challenge
For PKCE
Base64URL-encoded challenge
code_challenge_method
For PKCE
S256 or plain
nonce
For OIDC
Replay prevention token
redirect_uri
Yes
Callback URL
scope
Yes
Space-separated scopes
state
Yes
CSRF protection token
consent_action
Yes
approve or deny
code_challenge
For PKCE
Base64URL-encoded challenge
code_challenge_method
For PKCE
S256 or plain
nonce
For OIDC
Replay prevention token
redirect_uri
Yes
Same as authorization request
code_verifier
For PKCE
Original code verifier
password
Yes
User's password
scope
Optional
Requested scopes
code
Yes
Device code from /device/code
forward_url
Optional
Base64-encoded redirect URL
response_type
Yes
Must be code
client_id
Yes
The client identifier
redirect_uri
Yes
Callback URL (must match registered URI)
response_type
Yes
Must be code
client_id
Yes
Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
grant_type
Yes
authorization_code
code
Yes
grant_type
Yes
refresh_token
refresh_token
Yes
grant_type
Yes
client_credentials
scope
Optional
grant_type
Yes
password
username
Yes
Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
token
Yes
Token to introspect
token_type_hint
Optional
Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
token
Yes
Token to revoke
token_type_hint
Optional
Content-Type
application/x-www-form-urlencoded
client_id
Yes
Client identifier
grant_type
Yes
urn:ietf:params:oauth:grant-type:device_code
client_id
Yes
user_code
Optional
Pre-fill user code
user_code
Yes
User code from device
Authorization
Bearer {access_token}
forward_url
Optional
Base64-encoded redirect URL after login
email
Yes
User's email address
password
Yes
email
Yes
User's email address
email
Yes
User's email address
token
Yes
Reset token from email
password
Yes
token
Yes
Verification token from email
email
Yes
Email address
totp_code
Yes
6-digit verification code
totp_code
Yes
6-digit code from authenticator
password
Yes
Current password for verification
scope
The client identifier
Authorization code
Refresh token
Requested scopes
User's email
access_token or refresh_token
access_token or refresh_token
Client identifier
User's password
New password
linkedin
Apple
apple
error
string
Error code if authorization failed
error_description
string
Human-readable error message
400
invalid_code
Authorization code invalid or expired
500
provider_error
Error communicating with provider
400
cannot_unlink
Would leave account without login method
401
unauthorized
Not authenticated
Google OAuth client secret
GITHUB_OAUTH_ENABLED
Enable GitHub provider
GITHUB_CLIENT_ID
GitHub OAuth client ID
GITHUB_CLIENT_SECRET
GitHub OAuth client secret
FACEBOOK_OAUTH_ENABLED
Enable Facebook provider
FACEBOOK_CLIENT_ID
Facebook app ID
FACEBOOK_CLIENT_SECRET
Facebook app secret
LINKEDIN_OAUTH_ENABLED
Enable LinkedIn provider
LINKEDIN_CLIENT_ID
LinkedIn client ID
LINKEDIN_CLIENT_SECRET
LinkedIn client secret
APPLE_OAUTH_ENABLED
Enable Apple provider
APPLE_CLIENT_ID
Apple Services ID
APPLE_TEAM_ID
Apple Team ID
APPLE_KEY_ID
Apple Key ID
APPLE_PRIVATE_KEY
Apple private key (PEM format)
GET
/auth/{provider}/callback
OAuth callback handler
POST
/auth/{provider}/unlink
Unlink social account
google
GitHub
github
provider
string
Provider identifier (google, github, etc.)
forward_url
string
No
Base64-encoded URL to redirect after auth
400
invalid_provider
Provider not recognized
400
provider_disabled
provider
string
Provider identifier
code
string
Authorization code from provider
state
string
400
invalid_state
State parameter invalid or expired
400
authorization_denied
provider
string
Provider identifier to unlink
400
invalid_provider
Provider not recognized
400
not_linked
GOOGLE_OAUTH_ENABLED
Enable Google provider
GOOGLE_CLIENT_ID
Google OAuth client ID
facebook
Provider not enabled in settings
CSRF protection state parameter
User denied authorization
User doesn't have this provider linked
GOOGLE_CLIENT_SECRET
invalid_grant
400
Invalid authorization code or refresh token
unauthorized_client
400
Client not authorized for grant type
unsupported_grant_type
400
Grant type not supported
invalid_scope
400
Invalid scope requested
access_denied
401
User denied authorization
expired_token
400
Device code expired
400
Bad Request - Invalid parameters
401
Unauthorized - Authentication required
403
Forbidden - Insufficient permissions
404
Not Found - Resource doesn't exist
429
Too Many Requests - Rate limited
500
Server Error - Internal error
503
Service Unavailable - Temporarily down
account_locked
403
Account is locked
mfa_required
403
MFA verification required
invalid_parameter
400
Parameter format invalid
gone
410
Resource no longer available
invalid_redirect_uri
Mismatched redirect URI
Use exact registered URI
invalid_scope
Unknown scope
Use only registered scopes
invalid_token
Expired token
Refresh or re-authenticate
Review server logs for more context
unauthorized_client
Client not authorized for this grant type
access_denied
User denied authorization
unsupported_response_type
Response type not supported
invalid_scope
Invalid or unknown scope
server_error
Server encountered an error
temporarily_unavailable
Server is temporarily unavailable
invalid_request
400
Missing required parameter
invalid_client
401
invalid_request
400
Missing token parameter
invalid_client
401
authorization_pending
400
User hasn't completed authorization
slow_down
400
invalid_token
401
Token is invalid or expired
insufficient_scope
403
200
Success
201
Created
302
authentication_required
401
No valid authentication provided
invalid_credentials
401
validation_error
400
Request validation failed
missing_parameter
400
not_found
404
Resource not found
already_exists
409
rate_limit_exceeded
429
Too many requests
invalid_client
Wrong client credentials
Verify client_id and client_secret
invalid_grant
Expired or used code
Client authentication failed
Client authentication failed
Polling too frequently
Token lacks required scope
Redirect
Username or password incorrect
Required parameter missing
Resource already exists
Request new authorization code
RATE_LIMIT_WINDOW
60
Window in seconds
RATE_LIMIT_MAX
60
Max requests per window
/token
60
1 minute
/oauth2/userinfo
120
1 minute
/register
10
1 minute
/signin
10
1 minute
/forgot-password
3
1 hour
X-RateLimit-Limit
Maximum requests in window
X-RateLimit-Remaining
Requests remaining
X-RateLimit-Reset
RATE_LIMIT_ENABLED
true
Enable rate limiting
RATE_LIMIT_BY
ip
Unix timestamp when limit resets
Rate limit key
exp
Expiration time
iat
Issued at time
scope
Granted scopes
client_id
Client identifier
Check expiration is in the future
Check scope includes required permissions
token_type
String
Always Bearer
expires_in
Integer
Token lifetime in seconds
refresh_token
String
Token for renewal (optional)
scope
String
Granted scopes (space-separated)
id_token
String
OIDC ID token (when openid scope)
iss
Issuer (Authority URL)
sub
Subject (user ID)
aud
Audience (client ID)
PKCE is better - Authorization code + PKCE is now preferred
Note: Token is in URL fragment (#), not query string.
Replace implicit grant with authorization code + PKCE:
Before (Implicit):
After (PKCE):
Authorization Code + PKCE - Recommended replacement
OAuth 2.0 Reference - All grant types
Key type (RSA)
kid
Key ID (used in JWT header)
use
Key usage (sig for signing)
alg
Algorithm (RS256)
n
RSA modulus (Base64URL)
e
RSA exponent (Base64URL)
JWTs include the key ID in the header:
Match this kid with the key in JWKS.
JWKS should be cached to avoid excessive requests:
Authority may rotate keys periodically:
New key added to JWKS
New tokens signed with new key
Old key remains for existing token validation
Eventually old key is removed
Your verification code should:
Cache JWKS but refresh periodically
Handle multiple keys in the set
Match key by kid from token header
kty
POST /register/{client_id}/renew_secret
Authorization: Bearer {admin_token}{
"client_id": "abc123def456",
"client_secret": "new_secret_xyz789",
"client_secret_expires_at": 0
}POST /register/{client_id}/secrets
Authorization: Bearer {admin_token}
{
"action": "add"
}DELETE /register/{client_id}/secrets/{old_secret_id}
Authorization: Bearer {admin_token}POST /register/{client_id}/renew_secret
Authorization: Bearer {admin_token}# Example: Update Kubernetes secret
kubectl create secret generic oauth-secret \
--from-literal=client-secret=NEW_SECRET \
--dry-run=client -o yaml | kubectl apply -f -
# Rolling restart
kubectl rollout restart deployment/my-app#!/bin/bash
# rotate-secrets.sh
CLIENT_ID="abc123def456"
ADMIN_TOKEN="your_admin_token"
AUTHORITY_URL="https://auth.example.com"
# Rotate secret
NEW_SECRET=$(curl -s -X POST \
"${AUTHORITY_URL}/register/${CLIENT_ID}/renew_secret" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
| jq -r '.client_secret')
# Update Kubernetes secret
kubectl create secret generic oauth-secret \
--from-literal=client-secret="${NEW_SECRET}" \
--dry-run=client -o yaml | kubectl apply -f -
# Restart application
kubectl rollout restart deployment/my-app
# Notify team
echo "Client secret rotated for ${CLIENT_ID}" | \
slack-notify --channel "#security"# Rotate quarterly
0 0 1 */3 * /path/to/rotate-secrets.shCLIENT_SECRET_LIFETIME_DAYS=90GET /api/clients?secret_expires_before=2024-04-01
Authorization: Bearer {admin_token}GET /api/audit-logs?event=client.secret_rotated&client_id={client_id}
Authorization: Bearer {admin_token}# Notify on rotation
NOTIFY_ON_SECRET_ROTATION=true
NOTIFICATION_WEBHOOK=https://hooks.slack.com/...# Revoke all tokens for client
POST /api/clients/{client_id}/revoke_tokens
Authorization: Bearer {admin_token}crystal run src/tasks/create_admin.cr -- \
--email admin@example.com \
--name "Admin User" \
--password "SecurePassword123"ADMIN_EMAIL=admin@example.com \
ADMIN_PASSWORD=SecurePassword123 \
crystal run src/db/seed.crPOST /api/users
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"email": "newadmin@example.com",
"name": "New Admin",
"password": "SecurePassword123",
"role": "admin"
}PATCH /api/users/{id}
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"role": "admin"
}super_admin
└── admin
└── userREQUIRE_ADMIN_MFA=trueADMIN_PASSWORD_MIN_LENGTH=16
ADMIN_PASSWORD_EXPIRY_DAYS=30ADMIN_ALLOWED_IPS=10.0.0.0/8,192.168.1.100PATCH /api/users/{id}
Content-Type: application/json
Authorization: Bearer {super_admin_token}
{
"role": "user"
}PATCH /api/users/{id}
Content-Type: application/json
Authorization: Bearer {super_admin_token}
{
"active": false
}GET /api/users/{id}/sessions
Authorization: Bearer {admin_token}{
"data": [
{
"id": "session-uuid",
"created_at": "2024-01-15T10:30:00Z",
"last_activity": "2024-01-15T14:20:00Z",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...",
"device": "Chrome on macOS",
"location": "San Francisco, CA"
}
]
}DELETE /api/sessions/{session_id}
Authorization: Bearer {admin_token}DELETE /api/users/{id}/sessions
Authorization: Bearer {admin_token}SINGLE_SESSION=trueIDLE_TIMEOUT_MINUTES=30NOTIFY_NEW_SESSION=trueNew login to your Authority account
Device: Chrome on macOS
Location: San Francisco, CA
Time: January 15, 2024 at 10:30 AM
If this wasn't you, secure your account immediately.crystal run src/tasks/revoke_all_sessions.cr# Revoke sessions older than 30 days
DELETE /api/sessions?older_than=30d
Authorization: Bearer {admin_token}
# Revoke sessions from specific IP
DELETE /api/sessions?ip=192.168.1.100
Authorization: Bearer {admin_token}User Session
└── Access Token 1
└── Access Token 2
└── Refresh Token 1REVOKE_TOKENS_ON_SESSION_END=trueGET /api/metrics/sessions
Authorization: Bearer {admin_token}{
"active_sessions": 1250,
"sessions_today": 342,
"unique_users": 890
}Subject: Reset your password
Click the link below to reset your password:
https://auth.example.com/reset-password?token=abc123...
This link expires in 1 hour.
If you didn't request this, ignore this email.{% extends "emails/base.html" %}
{% block content %}
<h1>Reset Your Password</h1>
<p>Hi {{ user.name }},</p>
<p>Click the button below to reset your password:</p>
<a href="{{ reset_url }}" class="button">Reset Password</a>
<p>This link expires in {{ expiry_minutes }} minutes.</p>
{% endblock %}POST /api/users/{id}/send-password-reset
Authorization: Bearer {admin_token}PATCH /api/users/{id}
Content-Type: application/json
Authorization: Bearer {admin_token}
{
"password": "NewSecurePassword123"
}PASSWORD_RESET_RATE_LIMIT=3
PASSWORD_RESET_RATE_WINDOW=3600HIDE_EMAIL_EXISTENCE=trueIf an account with that email exists, a reset link has been sent.{% extends "layout.html" %}
{% set title = "Reset Password" %}
{% block body %}
<main class="reset-form">
<h1>Choose a New Password</h1>
<form action="/reset-password" method="post">
<input type="hidden" name="token" value="{{ token }}">
<input type="password" name="password" placeholder="New Password" required>
<input type="password" name="password_confirmation" placeholder="Confirm Password" required>
<button type="submit">Reset Password</button>
</form>
</main>
{% endblock %}<h1>Password Reset Complete</h1>
<p>Your password has been updated.</p>
<a href="/signin">Sign In</a>// Request reset
const response = await fetch('/api/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com' })
});
// Complete reset
const response = await fetch('/api/password-reset/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 'reset_token_from_email',
password: 'NewPassword123'
})
});public/templates/emails/
├── base.html # Base layout
├── welcome.html # Welcome email
├── verification.html # Email verification
├── password-reset.html # Password reset
├── mfa-enabled.html # MFA confirmation
└── new-login.html # New login alert<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
max-width: 150px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #7c3aed;
color: white;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<img src="{{ base_url }}/images/logo.png" alt="Authority" class="logo">
</div>
{% block content %}{% endblock %}
<div class="footer">
<p>This email was sent by {{ app_name }}.</p>
<p>{{ company_address }}</p>
</div>
</body>
</html>{% extends "emails/base.html" %}
{% block content %}
<h1>Reset Your Password</h1>
<p>Hi {{ user.name }},</p>
<p>We received a request to reset your password. Click the button below to choose a new password:</p>
<p style="text-align: center;">
<a href="{{ reset_url }}" class="button">Reset Password</a>
</p>
<p>This link will expire in {{ expiry_minutes }} minutes.</p>
<p>If you didn't request a password reset, you can safely ignore this email. Your password won't be changed.</p>
<p>
Best regards,<br>
The {{ app_name }} Team
</p>
{% endblock %}{% extends "emails/base.html" %}
{% block content %}
<h1>Welcome to {{ app_name }}!</h1>
<p>Hi {{ user.name }},</p>
<p>Thanks for creating an account. We're excited to have you on board!</p>
<p>Here are a few things you can do:</p>
<ul>
<li>Complete your profile</li>
<li>Enable two-factor authentication</li>
<li>Connect your applications</li>
</ul>
<p style="text-align: center;">
<a href="{{ profile_url }}" class="button">View Your Profile</a>
</p>
<p>
Welcome aboard,<br>
The {{ app_name }} Team
</p>
{% endblock %}SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=notifications@example.com
SMTP_PASSWORD=your_password
SMTP_FROM=noreply@example.com
SMTP_FROM_NAME=AuthorityEMAIL_VERIFICATION_REQUIRED=true
EMAIL_VERIFICATION_TTL=86400
PASSWORD_RESET_TTL=3600# Development mode shows emails in browser
CRYSTAL_ENV=development
EMAIL_PREVIEW=truecrystal run src/tasks/send_test_email.cr -- \
--to test@example.com \
--template password-reset<!-- Use inline styles -->
<p style="color: #333; font-size: 16px;">Text</p>
<!-- Use tables for layout -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>Content</td>
</tr>
</table>
<!-- Simple buttons -->
<a href="{{ url }}" style="
display: inline-block;
padding: 12px 24px;
background-color: #7c3aed;
color: #ffffff;
text-decoration: none;
">Button</a>public/templates/emails/
├── en/
│ ├── welcome.html
│ └── password-reset.html
├── es/
│ ├── welcome.html
│ └── password-reset.html
└── fr/
├── welcome.html
└── password-reset.htmlDEFAULT_LOCALE=en<img src="/images/logo.png" alt="{{ app_name }}" class="logo">:root {
/* Brand colors */
--primary-color: #7c3aed; /* Primary purple */
--primary-hover: #6d28d9; /* Darker purple */
--primary-light: #a78bfa; /* Lighter purple */
/* Accent colors */
--accent-color: #06b6d4; /* Cyan accent */
/* Status colors */
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--info: #3b82f6;
}:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: #334155;
}.theme-light {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-card: #ffffff;
--text-primary: #0f172a;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}APP_NAME=MyAuth
APP_TAGLINE=Secure authentication for everyone<title>{{ app_name }} - Sign In</title>
<p>{{ app_tagline }}</p><!-- In layout.html -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">:root {
--font-family: 'Inter', system-ui, sans-serif;
}
body {
font-family: var(--font-family);
}@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}.login-page {
background-image: url('/images/login-bg.jpg');
background-size: cover;
background-position: center;
}.login-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
padding: 2rem;
}<!-- emails/base.html -->
<div class="header" style="background-color: #7c3aed; padding: 20px; text-align: center;">
<img src="{{ base_url }}/images/logo-dark.png" alt="{{ app_name }}" style="max-width: 150px;">
</div><div class="footer" style="text-align: center; padding: 20px; color: #666;">
<p>© {{ year }} {{ company_name }}. All rights reserved.</p>
<p>{{ company_address }}</p>
</div><div class="client-info">
{% if client.logo_uri %}
<img src="{{ client.logo_uri }}" alt="{{ client.name }}" class="client-logo">
{% endif %}
<h2>{{ client.name }}</h2>
</div><ul class="scopes">
{% for scope in scopes %}
<li>
<i class="icon icon-{{ scope.name }}"></i>
{{ scope.description }}
</li>
{% endfor %}
</ul># Branding
APP_NAME=MyAuth
APP_TAGLINE=Secure authentication
COMPANY_NAME=My Company
COMPANY_ADDRESS=123 Main St, City
# URLs
LOGO_URL=/images/logo.png
FAVICON_URL=/images/favicon.ico
TERMS_URL=https://example.com/terms
PRIVACY_URL=https://example.com/privacy
SUPPORT_URL=https://example.com/support
# Theme
THEME=dark # dark or light
PRIMARY_COLOR=#7c3aedGET /authorize?response_type=code&client_id=abc123&redirect_uri=https://app.example.com/callback&scope=openid%20profile&state=xyz789{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"scope": "openid profile email"
}{
"active": true,
"client_id": "abc123",
"username": "user@example.com",
"scope": "openid profile email",
"sub": "user-uuid",
"token_type": "Bearer",
"exp": 1699999999,
"iat": 1699996399
}{
"active": false
}{
"device_code": "e2623df1-8594-47b4-b528-41ed3daecc1a",
"user_code": "56933A",
"verification_uri": "https://auth.example.com/activate",
"verification_uri_complete": "https://auth.example.com/activate?user_code=56933A",
"audience": "My Application",
"expires_in": 300,
"interval": 5
}{
"error": "authorization_pending",
"error_description": "The user has not yet completed authorization"
}{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid profile"
}{
"sub": "user-uuid",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email": "john@example.com",
"email_verified": true
}{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/oauth2/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"introspection_endpoint": "https://auth.example.com/oauth/introspect",
"revocation_endpoint": "https://auth.example.com/oauth/revoke",
"device_authorization_endpoint": "https://auth.example.com/device/code",
"scopes_supported": ["openid", "profile", "email", "read", "write"],
"response_types_supported": ["code", "token"],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"password",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"code_challenge_methods_supported": ["S256", "plain"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat",
"email", "email_verified", "name",
"given_name", "family_name"
]
}{
"keys": [
{
"kty": "RSA",
"kid": "authority-key-1",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}{
"client_name": "My Application",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email",
"logo_uri": "https://app.example.com/logo.png",
"client_uri": "https://app.example.com",
"tos_uri": "https://app.example.com/tos",
"policy_uri": "https://app.example.com/privacy",
"contacts": ["admin@example.com"]
}{
"client_id": "abc123-uuid",
"client_secret": "xyz789-secret",
"client_id_issued_at": 1699996399,
"client_secret_expires_at": 0,
"client_name": "My Application",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email"
}{
"success": true
}{
"error": "invalid_token"
}{
"status": "ok"
}GET /auth/{provider}# Basic social login
curl -I "https://auth.example.com/auth/google"
# With redirect URL
curl -I "https://auth.example.com/auth/google?forward_url=aHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20vZGFzaGJvYXJk"GET /auth/{provider}/callbackPOST /auth/{provider}/unlinkcurl -X POST "https://auth.example.com/auth/google/unlink" \
-H "Cookie: session=..." \
-H "Content-Type: application/json"{
"sub": "google-user-id",
"email": "user@gmail.com",
"email_verified": true,
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"picture": "https://lh3.googleusercontent.com/..."
}{
"id": 12345678,
"login": "johndoe",
"email": "john@example.com",
"name": "John Doe",
"avatar_url": "https://avatars.githubusercontent.com/..."
}{
"id": "facebook-user-id",
"email": "john@example.com",
"name": "John Doe",
"picture": {
"data": {
"url": "https://graph.facebook.com/..."
}
}
}{
"sub": "linkedin-member-id",
"email": "john@example.com",
"email_verified": true,
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"picture": "https://media.licdn.com/..."
}{
"sub": "apple-user-id",
"email": "john@example.com",
"email_verified": true,
"name": "John Doe"
}https://app.example.com/callback?error=access_denied&error_description=User%20denied%20accessHTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "invalid_grant",
"error_description": "The authorization code has expired"
}HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "authorization_pending",
"error_description": "The user has not yet completed authorization"
}HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
Content-Type: application/json
{
"error": "invalid_token",
"error_description": "The access token expired"
}{
"error": "validation_error",
"error_description": "Request validation failed",
"details": [
{
"field": "email",
"message": "Email format is invalid"
},
{
"field": "password",
"message": "Password must be at least 12 characters"
}
]
}HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"error_description": "Rate limit exceeded. Retry after 60 seconds.",
"retry_after": 60
}X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1699999999HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"error_description": "Too many requests. Please retry after 60 seconds.",
"retry_after": 60
}RATE_LIMIT_BY=ipRATE_LIMIT_BY=clientRATE_LIMIT_BY=user# Token endpoint: 60 requests per minute
RATE_LIMIT_TOKEN_MAX=60
RATE_LIMIT_TOKEN_WINDOW=60
# Login endpoint: 10 requests per minute
RATE_LIMIT_LOGIN_MAX=10
RATE_LIMIT_LOGIN_WINDOW=60
# Password reset: 3 requests per hour
RATE_LIMIT_PASSWORD_RESET_MAX=3
RATE_LIMIT_PASSWORD_RESET_WINDOW=3600RATE_LIMIT_WHITELIST=10.0.0.0/8,192.168.1.0/24{
"client_id": "trusted-client",
"rate_limits": {
"token": {
"max": 1000,
"window": 60
}
}
}async function makeRequest(url, options, retries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
if (retries > 0) {
const retryAfter = response.headers.get('Retry-After') || 60;
await sleep(retryAfter * 1000);
return makeRequest(url, options, retries - 1);
}
throw new Error('Rate limit exceeded');
}
return response;
}RATE_LIMIT_STORE=redis
REDIS_URL=redis://localhost:6379eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGhvcml0eS1rZXktMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLXV1aWQiLCJhdWQiOiJhYmMxMjMiLCJleHAiOjE2OTk5OTk5OTksImlhdCI6MTY5OTk5NjM5OSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCJ9.signature{
"alg": "RS256",
"typ": "JWT",
"kid": "authority-key-1"
}{
"iss": "https://auth.example.com",
"sub": "user-uuid",
"aud": "abc123",
"exp": 1699999999,
"iat": 1699996399,
"scope": "openid profile email",
"client_id": "abc123"
}{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid profile email",
"id_token": "..."
}{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid profile email"
}GET /api/resource HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...GET /api/resource?access_token=eyJhbGciOiJSUzI1NiIs...HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "invalid_grant",
"error_description": "The authorization code has expired"
}const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
function validateToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'your_client_id'
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
}POST /token/introspect HTTP/1.1
Authorization: Basic YWJjMTIzOnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
token=eyJhbGciOiJSUzI1NiIs...{
"active": true,
"client_id": "abc123",
"username": "user@example.com",
"scope": "openid profile email",
"sub": "user-uuid",
"exp": 1699999999
}Cache-Control: no-store
Pragma: no-cacheUser → Client → Authority → Client (with token in URL fragment)GET /authorize?
response_type=token
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile
&state=xyz789https://app.example.com/callback#
access_token=eyJhbGciOiJSUzI1NiIs...
&token_type=Bearer
&expires_in=3600
&state=xyz789// Redirect with response_type=token
window.location = '/authorize?response_type=token&client_id=...';
// Get token from URL fragment
const token = new URLSearchParams(window.location.hash.slice(1)).get('access_token');// Generate PKCE values
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Redirect with response_type=code
window.location = `/authorize?response_type=code&client_id=...&code_challenge=${challenge}&code_challenge_method=S256`;
// Exchange code for token
const tokenResponse = await fetch('/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
code_verifier: verifier,
client_id: CLIENT_ID
})
});{
"keys": [
{
"kty": "RSA",
"kid": "authority-key-1",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
}
]
}const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
jwksRequestsPerMinute: 10
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
return;
}
callback(null, key.getPublicKey());
});
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com'
}, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
}import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient(
"https://auth.example.com/.well-known/jwks.json"
)
def verify_token(token):
signing_key = jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="your_client_id"
)import (
"github.com/golang-jwt/jwt/v5"
"github.com/MicahParks/keyfunc/v2"
)
func verifyToken(tokenString string) (*jwt.Token, error) {
jwks, err := keyfunc.Get("https://auth.example.com/.well-known/jwks.json", keyfunc.Options{})
if err != nil {
return nil, err
}
return jwt.Parse(tokenString, jwks.Keyfunc, jwt.WithIssuer("https://auth.example.com"))
}{
"alg": "RS256",
"typ": "JWT",
"kid": "authority-key-1"
}const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
cacheMaxAge: 600000, // 10 minutes
timeout: 30000 // 30 seconds timeout
});const crypto = require('crypto');
async function manualVerify(token) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Decode header to get kid
const header = JSON.parse(Buffer.from(headerB64, 'base64url'));
// Fetch JWKS
const response = await fetch('https://auth.example.com/.well-known/jwks.json');
const jwks = await response.json();
// Find matching key
const jwk = jwks.keys.find(k => k.kid === header.kid);
if (!jwk) throw new Error('Key not found');
// Convert JWK to PEM
const pem = jwkToPem(jwk);
// Verify signature
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(`${headerB64}.${payloadB64}`);
const signature = Buffer.from(signatureB64, 'base64url');
const isValid = verifier.verify(pem, signature);
if (!isValid) throw new Error('Invalid signature');
return JSON.parse(Buffer.from(payloadB64, 'base64url'));
}redirect_uri
Callback URL
state
CSRF state parameter
public/
├── templates/
│ ├── layout.html # Base layout
│ ├── signin.html # Login page
│ ├── signup.html # Registration
│ ├── authorize.html # OAuth consent
│ ├── activate.html # Device activation
│ ├── forgot-password.html # Password reset request
│ ├── reset-password.html # Password reset form
│ ├── errors.html # Error messages
│ └── emails/ # Email templates
│ ├── verification.html
│ ├── password-reset.html
│ └── welcome.html
├── css/
│ └── styles.css # Main stylesheet
└── js/
└── app.js # Client JavaScript<h1>Welcome, {{ user.name }}</h1>
<p>Email: {{ user.email }}</p>{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
{% if user.mfa_enabled %}
<span class="badge">MFA Enabled</span>
{% endif %}<ul class="scopes">
{% for scope in scopes %}
<li>{{ scope.name }}: {{ scope.description }}</li>
{% endfor %}
</ul><!DOCTYPE html>
<html>
<head>
<title>{% block title %}Authority{% endblock %}</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
{% block body %}{% endblock %}
<script src="/js/app.js"></script>
</body>
</html>{% extends "layout.html" %}
{% set title = "Sign In" %}
{% block body %}
<main class="login-form">
{% include "errors.html" %}
<form action="/signin" method="post">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Sign In</button>
</form>
</main>
{% endblock %}forward_url
Redirect URL after login
error
Error message
username
client_name
OAuth client name
client_id
Client identifier
scopes
user_code
Pre-filled code
audience
Client name
error
:root {
/* Primary colors */
--primary-color: #7c3aed;
--primary-hover: #6d28d9;
/* Background colors */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #1e293b;
/* Text colors */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
/* Status colors */
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}/* Light theme */
.theme-light {
--bg-primary: #ffffff;
--bg-secondary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #64748b;
}TEMPLATES_PATH=/path/to/custom/templates# Restart server to reload templates
docker-compose restart authority


Previously entered username
Requested scopes
Error message
/token/introspect
POST
Validate a token
/token/revoke
POST
Revoke a token
/device
POST
Start device flow
RFC 7009 - Token Revocation
RFC 8628 - Device Authorization
Confidential
High
Mobile apps, SPAs
Public
High
Server-to-server
Confidential
High
IoT, CLI, Smart TVs
Public
Medium
Token renewal
Both
High
Legacy SPAs
Public
Low
Trusted first-party
Confidential
Medium
/authorize
GET
Start authorization flow
/token
POST
User → Your App → Authority → Your App Backend → TokenUser → Your App → Authority → Your App → Token (with PKCE)Your Service → Authority → TokenDevice → Authority → User on Browser → Device polls → TokenExchange code for tokens
Yes
Space-separated scopes
state
Recommended
CSRF protection token
redirect_uri
Yes
Same as authorization request
Short-lived codes - Authorization codes expire quickly
response_type
Yes
Must be code
client_id
Yes
Your client identifier
redirect_uri
Yes
URL to redirect after authorization
GET https://auth.example.com/authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email
&state=xyz789https://app.example.com/callback?code=AUTH_CODE_HERE&state=xyz789Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
grant_type
Yes
Must be authorization_code
code
Yes
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic YWJjMTIzOnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=https://app.example.com/callback{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"scope": "openid profile email"
}const express = require('express');
const crypto = require('crypto');
const app = express();
const CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTHORITY_URL = 'https://auth.example.com';
// Step 1: Redirect to authorization
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.state = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state
});
res.redirect(`${AUTHORITY_URL}/authorize?${params}`);
});
// Step 2: Handle callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state
if (state !== req.session.state) {
return res.status(400).send('Invalid state');
}
// Exchange code for tokens
const credentials = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
const tokenResponse = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI
})
});
const tokens = await tokenResponse.json();
req.session.tokens = tokens;
res.redirect('/profile');
});from flask import Flask, redirect, request, session
import requests
import secrets
app = Flask(__name__)
CLIENT_ID = 'your_client_id'
CLIENT_SECRET = 'your_client_secret'
REDIRECT_URI = 'http://localhost:5000/callback'
AUTHORITY_URL = 'https://auth.example.com'
@app.route('/login')
def login():
state = secrets.token_urlsafe(16)
session['state'] = state
params = {
'response_type': 'code',
'client_id': CLIENT_ID,
'redirect_uri': REDIRECT_URI,
'scope': 'openid profile email',
'state': state
}
return redirect(f"{AUTHORITY_URL}/authorize?{urlencode(params)}")
@app.route('/callback')
def callback():
code = request.args.get('code')
state = request.args.get('state')
if state != session.get('state'):
return 'Invalid state', 400
token_response = requests.post(
f"{AUTHORITY_URL}/token",
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': REDIRECT_URI
},
auth=(CLIENT_ID, CLIENT_SECRET)
)
session['tokens'] = token_response.json()
return redirect('/profile')scope
The authorization code
redirect_uri
Yes
Callback URL
scope
Yes
Space-separated scopes
state
Recommended
CSRF protection
code_challenge
Yes
Base64URL(SHA256(verifier))
code_challenge_method
Yes
S256
redirect_uri
Yes
Same as authorization
client_id
Yes
Client identifier
code_verifier
Yes
Original code verifier
code_verifier
Random string 43-128 characters
code_challenge
Base64URL(SHA256(code_verifier))
code_challenge_method
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(hash));
}
function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}import hashlib
import base64
import secrets
def generate_code_verifier():
return secrets.token_urlsafe(32)
def generate_code_challenge(verifier):
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()response_type
Yes
Must be code
client_id
Yes
GET https://auth.example.com/authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email
&state=xyz789
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256grant_type
Yes
authorization_code
code
Yes
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=https://app.example.com/callback
&client_id=abc123
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkimport { useEffect, useState } from 'react';
const CLIENT_ID = 'your_client_id';
const REDIRECT_URI = 'http://localhost:3000/callback';
const AUTHORITY_URL = 'https://auth.example.com';
function App() {
const [user, setUser] = useState(null);
useEffect(() => {
// Check for callback
if (window.location.search.includes('code=')) {
handleCallback();
}
}, []);
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateCodeVerifier();
// Store for callback
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${AUTHORITY_URL}/authorize?${params}`;
}
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Verify state
if (state !== sessionStorage.getItem('state')) {
throw new Error('Invalid state');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
// Exchange code for tokens
const response = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier
})
});
const tokens = await response.json();
// Get user info
const userResponse = await fetch(`${AUTHORITY_URL}/userinfo`, {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
});
setUser(await userResponse.json());
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('state');
window.history.replaceState({}, '', '/');
}
return (
<div>
{user ? (
<p>Welcome, {user.name}!</p>
) : (
<button onClick={login}>Login</button>
)}
</div>
);
}import * as AuthSession from 'expo-auth-session';
import * as Crypto from 'expo-crypto';
const CLIENT_ID = 'your_client_id';
const AUTHORITY_URL = 'https://auth.example.com';
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const redirectUri = AuthSession.makeRedirectUri();
const result = await AuthSession.startAsync({
authUrl: `${AUTHORITY_URL}/authorize?` + new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
});
if (result.type === 'success') {
const tokens = await exchangeCode(result.params.code, codeVerifier, redirectUri);
return tokens;
}
}S256 (recommended) or plain
Your client identifier
Authorization code
Use case
Service-to-service
User authentication
Client type
Confidential only
Both
Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
grant_type
Yes
Must be client_credentials
scope
Optional
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic YWJjMTIzOnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read%20write{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}Authorization: Basic base64(client_id:client_secret)POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=abc123
&client_secret=your_secretconst CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';
const AUTHORITY_URL = 'https://auth.example.com';
async function getAccessToken() {
const credentials = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
const response = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'read write'
})
});
const data = await response.json();
return data.access_token;
}
// Use the token
async function callApi() {
const token = await getAccessToken();
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.json();
}import requests
from requests.auth import HTTPBasicAuth
CLIENT_ID = 'your_client_id'
CLIENT_SECRET = 'your_client_secret'
AUTHORITY_URL = 'https://auth.example.com'
def get_access_token():
response = requests.post(
f'{AUTHORITY_URL}/token',
data={
'grant_type': 'client_credentials',
'scope': 'read write'
},
auth=HTTPBasicAuth(CLIENT_ID, CLIENT_SECRET)
)
return response.json()['access_token']
# Use the token
def call_api():
token = get_access_token()
response = requests.get(
'https://api.example.com/data',
headers={'Authorization': f'Bearer {token}'}
)
return response.json()# Get token
TOKEN=$(curl -s -X POST https://auth.example.com/token \
-u "client_id:client_secret" \
-d "grant_type=client_credentials" \
-d "scope=read" \
| jq -r '.access_token')
# Use token
curl https://api.example.com/data \
-H "Authorization: Bearer $TOKEN"class TokenCache {
constructor() {
this.token = null;
this.expiresAt = null;
}
async getToken() {
// Return cached token if still valid
if (this.token && Date.now() < this.expiresAt - 60000) {
return this.token;
}
// Get new token
const response = await fetchToken();
this.token = response.access_token;
this.expiresAt = Date.now() + (response.expires_in * 1000);
return this.token;
}
}
const tokenCache = new TokenCache();# Request specific scopes
grant_type=client_credentials&scope=read%20write
# If scope omitted, uses client's default scopes
grant_type=client_credentialsUser involved
No
Yes
Refresh token
No
Space-separated scopes
Yes
verification_uri_complete
URL with code pre-filled
expires_in
Seconds until codes expire
interval
Minimum polling interval
client_id
Yes
Client identifier
client_id
Yes
Client identifier
scope
Optional
POST /device HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
client_id=cli_client&scope=openid%20profile{
"device_code": "e2623df1-8594-47b4-b528-41ed3daecc1a",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/activate",
"verification_uri_complete": "https://auth.example.com/activate?user_code=WDJB-MJHT",
"expires_in": 300,
"interval": 5
}device_code
Code for token polling (keep secret)
user_code
Code user enters (6-8 characters)
verification_uri
To sign in, visit:
https://auth.example.com/activate
And enter the code:
WDJB-MJHT
Waiting for authorization...grant_type
Yes
urn:ietf:params:oauth:grant-type:device_code
device_code
Yes
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=e2623df1-8594-47b4-b528-41ed3daecc1a
&client_id=cli_client{
"error": "authorization_pending",
"error_description": "The user has not yet completed authorization"
}{
"error": "slow_down",
"error_description": "Polling too frequently"
}{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"scope": "openid profile"
}{
"error": "access_denied",
"error_description": "The user denied authorization"
}{
"error": "expired_token",
"error_description": "The device code has expired"
}const CLIENT_ID = 'cli_client';
const AUTHORITY_URL = 'https://auth.example.com';
async function login() {
// Step 1: Get device code
const deviceResponse = await fetch(`${AUTHORITY_URL}/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: 'openid profile email'
})
});
const device = await deviceResponse.json();
// Step 2: Display to user
console.log('\nTo sign in, visit:');
console.log(` ${device.verification_uri}`);
console.log('\nAnd enter the code:');
console.log(` ${device.user_code}`);
console.log('\nWaiting for authorization...\n');
// Step 3: Poll for token
const interval = device.interval * 1000;
const expiresAt = Date.now() + (device.expires_in * 1000);
while (Date.now() < expiresAt) {
await sleep(interval);
const tokenResponse = await fetch(`${AUTHORITY_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device.device_code,
client_id: CLIENT_ID
})
});
const result = await tokenResponse.json();
if (result.access_token) {
console.log('Login successful!');
return result;
}
if (result.error === 'authorization_pending') {
process.stdout.write('.');
continue;
}
if (result.error === 'slow_down') {
await sleep(5000); // Additional delay
continue;
}
throw new Error(result.error_description || result.error);
}
throw new Error('Device code expired');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Run
login()
.then(tokens => console.log('Access token:', tokens.access_token))
.catch(err => console.error('Error:', err.message));import requests
import time
CLIENT_ID = 'cli_client'
AUTHORITY_URL = 'https://auth.example.com'
def login():
# Step 1: Get device code
response = requests.post(
f'{AUTHORITY_URL}/device',
data={
'client_id': CLIENT_ID,
'scope': 'openid profile email'
}
)
device = response.json()
# Step 2: Display to user
print(f"\nTo sign in, visit:\n {device['verification_uri']}")
print(f"\nAnd enter the code:\n {device['user_code']}")
print("\nWaiting for authorization...")
# Step 3: Poll for token
interval = device['interval']
expires_at = time.time() + device['expires_in']
while time.time() < expires_at:
time.sleep(interval)
response = requests.post(
f'{AUTHORITY_URL}/token',
data={
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device['device_code'],
'client_id': CLIENT_ID
}
)
result = response.json()
if 'access_token' in result:
print("\nLogin successful!")
return result
if result.get('error') == 'authorization_pending':
print('.', end='', flush=True)
continue
if result.get('error') == 'slow_down':
time.sleep(5)
continue
raise Exception(result.get('error_description', result.get('error')))
raise Exception('Device code expired')
if __name__ == '__main__':
tokens = login()
print(f"Access token: {tokens['access_token']}")Enter the code displayed on your device:
┌─────────────────────────────────┐
│ WDJB-MJHT │
└─────────────────────────────────┘
[Continue]Requested scopes
URL user visits
Device code from initial response
scope
Optional
Request subset of original scopes
REFRESH_TOKEN_ROTATION
true
Rotate refresh tokens
refresh_token
Long-lived token for renewal
expires_in
Access token lifetime in seconds
Content-Type
application/x-www-form-urlencoded
Authorization
Basic {base64(client_id:client_secret)}
grant_type
Yes
Must be refresh_token
refresh_token
Yes
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic YWJjMTIzOnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=dGhpcyBpcyBhIHJlZnJlc2g{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "bmV3IHJlZnJlc2ggdG9rZW4...",
"token_type": "Bearer",
"expires_in": 3600
}class TokenManager {
constructor(tokens) {
this.tokens = tokens;
this.expiresAt = Date.now() + (tokens.expires_in * 1000);
this.scheduleRefresh();
}
scheduleRefresh() {
// Refresh 5 minutes before expiry
const refreshIn = this.expiresAt - Date.now() - (5 * 60 * 1000);
if (refreshIn > 0) {
setTimeout(() => this.refresh(), refreshIn);
}
}
async refresh() {
const response = await fetch('/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)}`
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.tokens.refresh_token
})
});
this.tokens = await response.json();
this.expiresAt = Date.now() + (this.tokens.expires_in * 1000);
this.scheduleRefresh();
return this.tokens;
}
getAccessToken() {
return this.tokens.access_token;
}
}async function apiCall(url, options = {}) {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${tokenManager.getAccessToken()}`
}
});
if (response.status === 401) {
// Token expired, refresh and retry
await tokenManager.refresh();
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${tokenManager.getAccessToken()}`
}
});
}
return response;
}{
"error": "invalid_grant",
"error_description": "The refresh token is invalid or expired"
}async function refresh() {
const response = await fetch('/token', {
method: 'POST',
// ...
});
if (!response.ok) {
const error = await response.json();
if (error.error === 'invalid_grant') {
// Refresh token invalid - require re-login
logout();
redirectToLogin();
return;
}
throw new Error(error.error_description);
}
return response.json();
}grant_type=refresh_token
&refresh_token=dGhpcyBpcyBhIHJlZnJlc2g
&scope=read # Originally had "read write"ACCESS_TOKEN_TTL
3600
Access token lifetime (seconds)
REFRESH_TOKEN_TTL
2592000
scope=openid profile email offline_accessPOST /token/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
token=dGhpcyBpcyBhIHJlZnJlc2g&token_type_hint=refresh_tokenThe refresh token
Refresh token lifetime (30 days)
Third-party applications
Public-facing apps
Any app you don't fully trust
POST /token
grant_type
Yes
Must be password
username
Yes
Credential exposure - App sees user's password
No consent - User doesn't explicitly authorize scopes
Phishing risk - Encourages entering passwords in apps
MFA bypass - May skip multi-factor authentication
To enable password grant (not recommended):
Replace password grant with proper OAuth flows:
Use Authorization Code + PKCE:
Use Authorization Code:
Authorization Code + PKCE - Recommended for mobile/SPA
Authorization Code - Recommended for web apps
Client Credentials - For machine-to-machine
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic YWJjMTIzOnNlY3JldA==
Content-Type: application/x-www-form-urlencoded
grant_type=password
&username=user@example.com
&password=secret123
&scope=openid%20profile{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"scope": "openid profile"
}ENABLE_PASSWORD_GRANT=true
PASSWORD_GRANT_ALLOWED_CLIENTS=trusted-app-1,trusted-app-2// Instead of collecting password in app
// Redirect to Authority's login page
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile',
code_challenge: challenge,
code_challenge_method: 'S256'
});
// Open in-app browser
openAuthUrl(`${AUTHORITY_URL}/authorize?${params}`);// Redirect to Authority for login
window.location = `${AUTHORITY_URL}/authorize?response_type=code&...`;Discovery - Automatic configuration
/.well-known/openid-configuration
Discovery document
/.well-known/jwks.json
Public keys for verification
/userinfo
Request OIDC scopes to get identity information:
openid
sub (required for OIDC)
profile
name, family_name, given_name, picture, etc.
email
The ID token is a JWT containing user identity:
See ID Tokens for full specification.
See UserInfo for details.
Automatic configuration via:
Response includes all endpoints, supported scopes, algorithms, etc.
See Discovery for full specification.
Purpose
Authorization
Authentication
Token
Access token
Authority implements:
{
"iss": "https://auth.example.com",
"sub": "user-uuid",
"aud": "client-id",
"exp": 1699999999,
"iat": 1699996399,
"auth_time": 1699996300,
"nonce": "abc123",
"name": "John Doe",
"email": "john@example.com",
"email_verified": true
}{
"sub": "user-uuid",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email": "john@example.com",
"email_verified": true,
"picture": "https://..."
}GET /.well-known/openid-configuration// 1. Request authorization with openid scope
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: state,
nonce: nonce // For ID token validation
});
window.location = `${AUTHORITY_URL}/authorize?${params}`;
// 2. Exchange code for tokens
const tokens = await fetch('/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI
})
}).then(r => r.json());
// 3. Validate and use ID token
const idToken = parseIdToken(tokens.id_token);
console.log('User:', idToken.name);
// 4. Optionally fetch more claims
const userinfo = await fetch('/userinfo', {
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
}).then(r => r.json());Provider identifier (base URL)
authorization_endpoint
URL for authorization requests
token_endpoint
URL for token requests
userinfo_endpoint
URL for user information
jwks_uri
URL for JSON Web Key Set
registration_endpoint
Dynamic client registration
revocation_endpoint
Token revocation
introspection_endpoint
scopes_supported
Available scopes
response_types_supported
Supported response types
grant_types_supported
Discovery documents should be cached:
Clients should verify:
issuer matches the expected provider
Required endpoints are present
Needed scopes/grant types are supported
Signing algorithms are acceptable
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"registration_endpoint": "https://auth.example.com/register",
"revocation_endpoint": "https://auth.example.com/token/revoke",
"introspection_endpoint": "https://auth.example.com/token/introspect",
"device_authorization_endpoint": "https://auth.example.com/device",
"scopes_supported": [
"openid",
"profile",
"email",
"address",
"phone",
"offline_access"
],
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token"
],
"response_modes_supported": [
"query",
"fragment"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials",
"urn:ietf:params:oauth:grant-type:device_code"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"none"
],
"claims_supported": [
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"name",
"given_name",
"family_name",
"email",
"email_verified",
"picture",
"locale"
],
"code_challenge_methods_supported": [
"S256",
"plain"
]
}issuer
async function discoverProvider(issuer) {
const response = await fetch(
`${issuer}/.well-known/openid-configuration`
);
return response.json();
}
// Use discovered configuration
const config = await discoverProvider('https://auth.example.com');
// Now use the endpoints
const authUrl = new URL(config.authorization_endpoint);
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('response_type', 'code');
// ...import requests
def discover_provider(issuer):
response = requests.get(
f"{issuer}/.well-known/openid-configuration"
)
return response.json()
config = discover_provider('https://auth.example.com')
print(f"Auth endpoint: {config['authorization_endpoint']}")class ProviderConfig {
constructor(issuer) {
this.issuer = issuer;
this.config = null;
this.expiresAt = null;
}
async getConfig() {
// Cache for 1 hour
if (this.config && Date.now() < this.expiresAt) {
return this.config;
}
const response = await fetch(
`${this.issuer}/.well-known/openid-configuration`
);
this.config = await response.json();
this.expiresAt = Date.now() + (60 * 60 * 1000);
return this.config;
}
}function validateConfig(config, expectedIssuer) {
if (config.issuer !== expectedIssuer) {
throw new Error('Issuer mismatch');
}
if (!config.authorization_endpoint) {
throw new Error('Missing authorization endpoint');
}
if (!config.scopes_supported.includes('openid')) {
throw new Error('OpenID scope not supported');
}
return true;
}sub
String
Subject identifier
name
String
Full name
given_name
email
String
Email address
email_verified
Boolean
address
Object
Address object
Address object:
phone_number
String
Phone number
phone_number_verified
Boolean
Format
JWT (signed)
JSON
When received
Token response
Use ID token for authentication, UserInfo for current profile data.
UserInfo responses can be cached briefly:
GET /userinfo HTTP/1.1
Host: auth.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...POST /userinfo HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
access_token=eyJhbGciOiJSUzI1NiIs...{
"sub": "user-uuid",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"preferred_username": "johnd",
"email": "john@example.com",
"email_verified": true,
"picture": "https://example.com/johnd/photo.jpg",
"locale": "en-US",
"updated_at": 1699996399
}{
"formatted": "123 Main St\nCity, State 12345",
"street_address": "123 Main St",
"locality": "City",
"region": "State",
"postal_code": "12345",
"country": "US"
}async function getUserInfo(accessToken) {
const response = await fetch('https://auth.example.com/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch user info');
}
return response.json();
}
// Usage
const user = await getUserInfo(tokens.access_token);
console.log(`Hello, ${user.name}!`);import requests
def get_userinfo(access_token):
response = requests.get(
'https://auth.example.com/userinfo',
headers={'Authorization': f'Bearer {access_token}'}
)
if response.status_code != 200:
raise Exception('Failed to fetch user info')
return response.json()
user = get_userinfo(tokens['access_token'])
print(f"Hello, {user['name']}!")HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token is invalid"
{
"error": "invalid_token",
"error_description": "The access token is invalid"
}HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token has expired"
{
"error": "invalid_token",
"error_description": "The access token has expired"
}HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="openid profile"
{
"error": "insufficient_scope",
"error_description": "The access token lacks the required scope"
}class UserInfoCache {
constructor(ttl = 60000) { // 1 minute
this.cache = new Map();
this.ttl = ttl;
}
async get(accessToken) {
const cached = this.cache.get(accessToken);
if (cached && Date.now() < cached.expiresAt) {
return cached.data;
}
const data = await fetchUserInfo(accessToken);
this.cache.set(accessToken, {
data,
expiresAt: Date.now() + this.ttl
});
return data;
}
}sub
Subject (unique user ID)
aud
Audience (client ID)
exp
Expiration time
iat
Issued at time
auth_time
Time of authentication
nonce
Value from authorization request
at_hash
name
Full name
given_name
First name
family_name
Verify signature using JWKS
Check iss matches expected issuer
Check aud contains your client ID
Check exp is in the future
Check iat is reasonable
Check nonce matches sent value (if used)
Nonce prevents replay attacks:
The at_hash claim allows ID token to be bound to access token:
Check JWKS URL is correct
Verify key ID (kid) matches
Ensure algorithm is RS256
Check server/client time sync
Token may have short lifetime
Refresh authentication
Verify client ID in validation
Check token was issued for your client
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGhvcml0eS1rZXktMSJ9.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLXV1aWQi...
.signature{
"alg": "RS256",
"typ": "JWT",
"kid": "authority-key-1"
}{
"iss": "https://auth.example.com",
"sub": "user-uuid",
"aud": "client-id",
"exp": 1699999999,
"iat": 1699996399,
"auth_time": 1699996300,
"nonce": "n-0S6_WzA2Mj",
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
"name": "John Doe",
"email": "john@example.com",
"email_verified": true
}iss
Issuer (Authority URL)
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});
async function validateIdToken(idToken, expectedNonce) {
return new Promise((resolve, reject) => {
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
};
jwt.verify(idToken, getKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'your_client_id'
}, (err, decoded) => {
if (err) {
reject(err);
return;
}
// Check nonce
if (expectedNonce && decoded.nonce !== expectedNonce) {
reject(new Error('Invalid nonce'));
return;
}
// Check auth_time if max_age was requested
// Check other claims as needed
resolve(decoded);
});
});
}import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
def validate_id_token(id_token, expected_nonce=None):
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
issuer="https://auth.example.com",
audience="your_client_id"
)
if expected_nonce and claims.get('nonce') != expected_nonce:
raise ValueError('Invalid nonce')
return claimsconst nonce = generateRandomString();
sessionStorage.setItem('oidc_nonce', nonce);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
nonce: nonce
});const savedNonce = sessionStorage.getItem('oidc_nonce');
const claims = await validateIdToken(tokens.id_token, savedNonce);
sessionStorage.removeItem('oidc_nonce');function validateAtHash(idToken, accessToken) {
const claims = jwt.decode(idToken);
if (claims.at_hash) {
const hash = crypto.createHash('sha256').update(accessToken).digest();
const expectedHash = hash.slice(0, hash.length / 2);
const calculatedAtHash = base64url.encode(expectedHash);
if (claims.at_hash !== calculatedAtHash) {
throw new Error('at_hash mismatch');
}
}
}4000
HTTP server port
HOST
0.0.0.0
Bind address
BASE_URL
http://localhost:4000
Public URL
CRYSTAL_WORKERS
4
Worker processes
DATABASE_URL
Required
PostgreSQL connection string
SECRET_KEY
Required
JWT signing key (256-bit)
See Security Settings for security-specific options.
ACCESS_TOKEN_TTL
3600
Access token lifetime (seconds)
CODE_TTL
600
See Token Settings for token-specific options.
SESSION_KEY
session_id
Session cookie name
SESSION_DURATION_DAYS
7
SSL_CERT
Path to certificate
SSL_KEY
TEMPLATES_PATH
./public/templates
Template directory
CRYSTAL_LOG_LEVEL
debug
Log level
CRYSTAL_LOG_SOURCES
*
SMTP_HOST
SMTP server host
SMTP_PORT
587
REDIS_URL
Redis connection URL
RATE_LIMIT_ENABLED
true
Enable rate limiting
RATE_LIMIT_BY
ip
APP_NAME
Authority
Application name
APP_TAGLINE
ENABLE_PASSWORD_GRANT
false
Enable password grant
PASSWORD_GRANT_ALLOWED_CLIENTS
Security Settings - Security configuration
Token Settings - Token configuration
Environment Variables - Setup guide
CRYSTAL_ENV
development
Environment: development, production, test
PORT
CRYSTAL_ENV=development
PORT=4000
BASE_URL=http://localhost:4000
DATABASE_URL=postgres://localhost:5432/authority_db
SECRET_KEY=dev-secret-key
CRYSTAL_LOG_LEVEL=debugCRYSTAL_ENV=production
PORT=4000
BASE_URL=https://auth.example.com
DATABASE_URL=postgres://user:pass@db:5432/authority_db?sslmode=require
SECRET_KEY=your-production-secret-key
CRYSTAL_WORKERS=8
CRYSTAL_LOG_LEVEL=info
# Security
LOCKOUT_THRESHOLD=5
LOCKOUT_DURATION=30
PASSWORD_MIN_LENGTH=12
REQUIRE_ADMIN_MFA=true
# Tokens
ACCESS_TOKEN_TTL=3600
REFRESH_TOKEN_TTL=2592000
# Redis
REDIS_URL=redis://redis:6379
# Email
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=noreply@example.comLOCKOUT_DURATION
30
Lockout duration (minutes)
ENABLE_AUTO_UNLOCK
true
Auto-unlock after duration
PROGRESSIVE_LOCKOUT
false
Increase duration with each lockout
LOCKOUT_BY_IP
false
Lock by IP instead of account
IP_LOCKOUT_THRESHOLD
10
IP-based lockout threshold
LOCKOUT_WHITELIST
IPs exempt from lockout
PASSWORD_MIN_LENGTH
12
Minimum password length
PASSWORD_HISTORY_COUNT
5
ADMIN_PASSWORD_MIN_LENGTH
16
Admin minimum length
ADMIN_PASSWORD_EXPIRY_DAYS
30
REQUIRE_MFA
false
Require MFA for all users
REQUIRE_ADMIN_MFA
true
SESSION_DURATION_DAYS
7
Maximum session lifetime
IDLE_TIMEOUT_MINUTES
30
ADMIN_ALLOWED_IPS
IP whitelist for admin
HIDE_EMAIL_EXISTENCE
true
PASSWORD_RESET_TTL
3600
Reset token lifetime (seconds)
PASSWORD_RESET_RATE_LIMIT
3
SECRET_KEY
Required
JWT signing key
JWT_ALGORITHM
RS256
AUDIT_LOG_RETENTION_DAYS
90
Log retention period
AUDIT_LOG_SYSLOG
false
Token Settings - Token configuration
All Options - Complete reference
Enable MFA - MFA setup
LOCKOUT_THRESHOLD
5
Failed attempts before lockout
# Strict account lockout
LOCKOUT_THRESHOLD=3
LOCKOUT_DURATION=60
PROGRESSIVE_LOCKOUT=true
# Strong passwords
PASSWORD_MIN_LENGTH=16
REQUIRE_UPPERCASE=true
REQUIRE_LOWERCASE=true
REQUIRE_NUMBERS=true
REQUIRE_SPECIAL=true
PASSWORD_EXPIRY_DAYS=90
CHECK_COMMON_PASSWORDS=true
# MFA required
REQUIRE_MFA=true
REQUIRE_ADMIN_MFA=true
# Short sessions
SESSION_DURATION_DAYS=1
IDLE_TIMEOUT_MINUTES=15
SINGLE_SESSION=true
# Notifications
NOTIFY_NEW_SESSION=true
# IP restrictions
ADMIN_ALLOWED_IPS=10.0.0.0/8# Lenient lockout
LOCKOUT_THRESHOLD=10
LOCKOUT_DURATION=15
ENABLE_AUTO_UNLOCK=true
# Reasonable passwords
PASSWORD_MIN_LENGTH=10
REQUIRE_UPPERCASE=false
REQUIRE_LOWERCASE=false
REQUIRE_NUMBERS=false
PASSWORD_EXPIRY_DAYS=0
# MFA optional except admins
REQUIRE_MFA=false
REQUIRE_ADMIN_MFA=true
# Longer sessions
SESSION_DURATION_DAYS=30
IDLE_TIMEOUT_MINUTES=602592000
Refresh token lifetime (30 days)
CODE_TTL
600
Authorization code lifetime (10 min)
DEVICE_CODE_TTL
300
Device code lifetime (5 min)
ID_TOKEN_TTL
3600
ID token lifetime (1 hour)
REFRESH_TOKEN_ROTATION
true
Rotate on refresh
REFRESH_TOKEN_REUSE_INTERVAL
0
ACCESS_TOKEN_FORMAT
jwt
jwt or opaque
JWT_ALGORITHM
RS256
Configure per-client token settings via API:
iss
Issuer (Authority URL)
sub
Subject (user ID)
aud
iss
Issuer
sub
Subject
aud
INTROSPECTION_CACHE_TTL
60
Cache introspection results
REVOKE_REFRESH_ON_PASSWORD_CHANGE
true
Revoke on password change
REVOKE_ALL_ON_LOGOUT
false
Keep short-lived (15 min - 1 hour)
Use for API authorization only
Validate on each request
Enable rotation
Set reasonable lifetime
Revoke on security events
Very short-lived (5-10 minutes)
Single use only
Bind to client
Security Settings - Security configuration
All Options - Complete reference
Token Lifecycle - Conceptual overview
ACCESS_TOKEN_TTL
3600
Access token lifetime (seconds)
REFRESH_TOKEN_TTL
{
"client_id": "my-client",
"access_token_ttl": 7200,
"refresh_token_ttl": 604800,
"refresh_token_rotation": true
}ACCESS_TOKEN_TTL=900 # 15 minutes
REFRESH_TOKEN_TTL=86400 # 1 day
REFRESH_TOKEN_ROTATION=true
CODE_TTL=300 # 5 minutesACCESS_TOKEN_TTL=86400 # 1 day
REFRESH_TOKEN_TTL=7776000 # 90 days
REFRESH_TOKEN_ROTATION=falseACCESS_TOKEN_TTL=3600 # 1 hour
REFRESH_TOKEN_TTL=0 # DisabledAuth Endpoints - Login, logout, password reset
Business logic is encapsulated in services:
AuthenticationService
User authentication, MFA validation
TokenService
Token generation, validation, revocation
UserService
Data access and persistence:
Models - Data structures (User, Client, Token, etc.)
Repositories - Database queries
Migrations - Schema management
Signing - JWTs signed with RS256
Rotation - Refresh tokens rotated on use
Revocation - Tokens can be invalidated
Expiration - Short-lived access tokens
Authority supports multiple instances:
Shared state stored in Redis:
User sessions
Rate limit counters
Token cache (optional)
Language
Crystal
Web Framework
Azu
Database
Security Model - Security architecture
Token Lifecycle - Token management
OAuth 2.0 Concepts - Protocol fundamentals
users
├── id (UUID)
├── email (unique)
├── password_hash
├── name
├── mfa_secret
├── mfa_enabled
├── locked
├── locked_at
├── failed_attempts
├── role
├── created_at
└── updated_atclients
├── id (UUID)
├── client_id (unique)
├── client_secret_hash
├── name
├── redirect_uris (array)
├── grant_types (array)
├── scopes (array)
├── client_type
├── created_at
└── updated_at┌─────────────────────────────────────┐
│ Rate Limiting │
├─────────────────────────────────────┤
│ Input Validation │
├─────────────────────────────────────┤
│ Authentication │
├─────────────────────────────────────┤
│ Authorization │
├─────────────────────────────────────┤
│ Business Logic │
├─────────────────────────────────────┤
│ Data Validation │
├─────────────────────────────────────┤
│ Audit Logging │
└─────────────────────────────────────┘ ┌─────────────┐
│ Load │
│ Balancer │
└──────┬──────┘
┌──────────────┼──────────────┐
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Authority │ │ Authority │ │ Authority │
│ Instance │ │ Instance │ │ Instance │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
└──────────────┼──────────────┘
┌──────┴──────┐
┌─────────────┴─────────────┴─────────────┐
│ PostgreSQL │
│ (Primary) │
└─────────────┬─────────────┬─────────────┘
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ Replica │ │ Replica │
└───────────┘ └───────────┘OAuth provides:
Delegated authorization (no password sharing)
Scoped access (limited permissions)
Revocable tokens (easy access removal)
Resource Owner
The user who owns the data
Client
The application requesting access
Authorization Server
Authorization Code
Exchanged for tokens
Minutes
Access Token
Authorizes API requests
Scopes define what access is granted:
Clients request scopes:
Users consent to scopes:
Best for: Web applications with a backend
The client never sees the user's password.
Best for: Mobile apps, single-page applications
Same as authorization code, but with proof key:
Client generates random code_verifier
Client sends code_challenge = SHA256(code_verifier)
Authority returns code
Client proves identity with code_verifier
Best for: Machine-to-machine
No user involved - the service itself is authorized.
Best for: TVs, CLI tools, IoT
User wants to access protected resource
User clicks "Login with Authority" in your app.
Client redirects to Authorization Server
GET /authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=profile email
&state=xyz789User authenticates
Authority shows login page. User enters credentials.
User authorizes
Authority shows consent screen. User approves scopes.
Authorization Server redirects back
Client exchanges code for tokens
Client receives tokens
Client uses access token
Prevents CSRF attacks:
Client generates random state
Client stores state in session
Client includes state in authorization request
Authority returns state in callback
Client verifies state matches
Prevents authorization code interception:
Short-lived access tokens - Limit exposure window
Token rotation - New refresh token each use
Secure storage - Never store in URL or logs
OAuth is for authorization (what you can do), not authentication (who you are). OpenID Connect adds authentication.
Access tokens authorize access - they don't necessarily contain user info. Use the UserInfo endpoint or ID tokens for identity.
Shorter lifetimes with refresh tokens are more secure. If a token is compromised, the damage window is limited.
OpenID Connect Concepts - Identity layer
Choosing Grant Types - Decision guide
Token Lifecycle - Token management
┌──────────────────────────────────────────────────┐
│ │
│ Resource Owner Authorization Server │
│ (User) (Authority) │
│ │ │ │
│ │ authorizes │ issues tokens │
│ ▼ ▼ │
│ Client ◄─────────────────────────────────► │
│ (App) Resource Server │
│ (API) │
│ │
└──────────────────────────────────────────────────┘Full User Data
├── profile (name, picture)
├── email (email address)
├── read (read-only access)
├── write (modify data)
└── admin (administrative access)scope=profile email read"App X wants to access your profile and email"User → Client → Authority → User (login) → Client (code) → Authority → Client (tokens)Service → Authority (client_id + secret) → Service (token)Device → Authority (get code)
Device → User: "Go to URL, enter code"
User → Authority (enter code, approve)
Device → Authority (poll for token) Attacker
│
┌─────────┐ │ ┌─────────┐
│ Client │───code_challenge───┼───────────────────►│Authority│
│ │◄──authorization_code◄──────────────────│ │
│ │───code_verifier────┼────────────────────►│ │
│ │◄──access_token─────┼────────────────────│ │
└─────────┘ │ └─────────┘
│
Attacker has code,
but can't prove
they know verifierQuestion
"What can they do?"
"Who are they?"
Result
Access token
ID token + Access token
Use case
API access
User login
OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 that adds identity.
OIDC adds:
ID Token - Proof of authentication
UserInfo Endpoint - User profile data
Standard Scopes - openid, profile, email
Discovery - Automatic configuration
The ID token is a JWT that proves authentication:
iss
Who issued the token
sub
Unique user identifier
aud
Request identity information with scopes:
openid
sub (required)
profile
name, picture, etc.
email
For
Client
Resource Server
Purpose
Prove identity
Get more user data with the access token:
Response:
When
Token response
Separate request
Format
JWT (signed)
Use ID token for authentication proof. Use UserInfo for current profile data.
OIDC providers publish configuration:
Response:
Clients use discovery to automatically configure themselves.
Prevents token replay attacks:
Client generates random nonce
Client sends nonce in authorization request
Authority includes nonce in ID token
Client verifies nonce matches
Always validate:
Signature - Using JWKS
Issuer - Matches expected
Audience - Contains your client ID
Expiration - Not expired
Nonce - If used, matches sent value
ID tokens are signed, not encrypted
Claims can be read by anyone
Don't put sensitive data in ID tokens
ID tokens prove identity at login time
Don't use ID tokens for API authorization
Use access tokens for API calls
OAuth 2.0 Concepts - Authorization fundamentals
Token Lifecycle - Token management
ID Tokens Reference - Technical details
Purpose
Authorization
Authentication
┌────────────────────────────────────────┐
│ OpenID Connect │
│ (Authentication) │
├────────────────────────────────────────┤
│ OAuth 2.0 │
│ (Authorization) │
└────────────────────────────────────────┘{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "client-abc",
"exp": 1699999999,
"iat": 1699996399,
"auth_time": 1699996300,
"nonce": "abc123",
"name": "John Doe",
"email": "john@example.com"
}scope=openid profile emailID Token: "This is John, authenticated 5 minutes ago"
→ Use for login decisions
Access Token: "John has read access to documents"
→ Use for API authorizationGET /userinfo
Authorization: Bearer access_token{
"sub": "user-123",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email": "john@example.com",
"picture": "https://..."
}GET /.well-known/openid-configuration{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json"
}User clicks "Login" → Redirect to Authority → User logs in →
Redirect back with tokens → User is logged inUser logged into Authority → Visits App A → Already authenticated →
Visits App B → Already authenticated (no re-login)Client → Authority (prompt=none) →
If logged in: Return tokens
If not: Return errorHistory-checked - Prevent reuse
TOTP-based MFA provides:
Something you know - Password
Something you have - Authenticator app
MFA protects against:
Credential stuffing
Phishing
Password breaches
Progressive lockout:
Protects against brute-force attacks while limiting denial-of-service impact.
Tokens are signed with RS256:
Private key - Signs tokens (server only)
Public key - Verifies tokens (anyone)
Key rotation - Periodic key changes
Authorization Code
10 minutes
Minimize interception window
Access Token
1 hour
Refresh tokens rotate on use:
If a refresh token is stolen, the attacker races with the legitimate client.
Tokens can be revoked:
User logout
Password change
Admin action
Security concern
Sessions are bound to:
User agent
IP address (optional)
Expiration time
Absolute timeout - Maximum session lifetime
Idle timeout - Inactivity limit
Single session - One active session per user (optional)
Exact match required
No wildcards
HTTPS in production
Pre-registered only
Prevents CSRF:
Protects public clients:
Passwords
Hashed (bcrypt)
Never retrievable
MFA secrets
Encrypted
Connection encryption (SSL)
Prepared statements (SQL injection prevention)
Principle of least privilege
Regular backups
Authentication attempts (success/failure)
Token operations (issue/revoke)
Admin actions
Configuration changes
Each log entry includes:
Timestamp
Actor (user/client/system)
Action
Resource
IP address
User agent
Changes made
Default: 90 days
Configurable per compliance requirements
Export for external systems
Credential stuffing
Lockout, MFA, rate limiting
Token theft
Short lifetimes, rotation, binding
CSRF
Always use HTTPS - No exceptions
Use strong secrets - Cryptographically random
Enable MFA - At least for admins
Monitor logs - Regular review
Keep updated - Apply security patches
Validate tokens - Always verify signatures
Use PKCE - For all public clients
Store securely - No localStorage for sensitive tokens
Handle errors - Don't leak information
Implement logout - Revoke tokens properly
Token Lifecycle - Token management details
Architecture - System design
Enable MFA - MFA setup guide
┌─────────────────────────────────────────┐
│ Network Security (HTTPS) │
├─────────────────────────────────────────┤
│ Rate Limiting │
├─────────────────────────────────────────┤
│ Input Validation │
├─────────────────────────────────────────┤
│ Authentication (Password + MFA) │
├─────────────────────────────────────────┤
│ Session Management │
├─────────────────────────────────────────┤
│ Authorization (Scopes) │
├─────────────────────────────────────────┤
│ Audit Logging │
└─────────────────────────────────────────┘Attempt 1-4: Normal login
Attempt 5: Account locked (30 min)
Attempt 6+: Lock timer resetsRequest with Refresh Token A
↓
Issue new Access Token
Issue new Refresh Token B
Invalidate Refresh Token AClient generates: state=abc123
Client sends in request
Authority returns: state=abc123
Client verifies matchcode_verifier = random()
code_challenge = SHA256(code_verifier)
Authorization: Send code_challenge
Token exchange: Prove with code_verifierPKCE-protected - Proof of possession (public clients)
Authorizes API requests. Presented to resource servers.
Authority issues JWTs:
Resource servers validate by:
Verifying JWT signature (JWKS)
Checking expiration
Verifying issuer and audience
Checking required scopes
Obtains new access tokens without user interaction.
With rotation enabled:
Optional reuse window for concurrent requests:
Proves user authentication. Contains identity claims.
Must validate:
Signature (JWKS)
Issuer matches expected
Audience contains client ID
Expiration not passed
Nonce matches (if sent)
Tokens are revoked when:
User logs out
User changes password
Admin revokes manually
Security incident detected
Session ends
For real-time validation:
Access
Memory
Clear on logout
Refresh
Secure cookie
High Security
15 minutes
1 day
Standard
1 hour
Proactive:
Reactive:
Security Model - Security architecture
Refresh Tokens Reference - Technical details
Token Settings - Configuration
Creation: User approves consent
Lifetime: 10 minutes (configurable)
Exchange: POST /token (one-time use)
Deletion: After exchange or expirationCreation: Token endpoint (code exchange or refresh)
Lifetime: 1 hour (configurable)
Usage: Bearer token in API requests
Expiration: Rejected after TTL{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "client-abc",
"exp": 1699999999,
"scope": "read write"
}Creation: Token endpoint (with offline_access scope)
Lifetime: 30 days (configurable)
Usage: POST /token with grant_type=refresh_token
Rotation: New refresh token on each useTime T: Client has Refresh Token A
Time T+1: Client uses Refresh Token A
↓
New Access Token issued
New Refresh Token B issued
Refresh Token A invalidated
Time T+2: Attacker tries Refresh Token A
↓
Rejected (already used)
Alert: Possible token theftRefresh Token A used at T
Refresh Token A still valid for 2 seconds
After 2 seconds: Only Refresh Token B validCreation: Token endpoint (with openid scope)
Lifetime: 1 hour (typically)
Usage: Validate at login time
Storage: Client-side (for user info display)Session Revoked
├── Access Tokens invalidated
└── Refresh Tokens invalidatedPOST /token/introspect
↓
{
"active": false // Token revoked
}┌─────────────────────────────────────────┐
│ PostgreSQL │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Access │ │ Refresh │ │ Auth │ │
│ │ Tokens │ │ Tokens │ │ Codes │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Redis │
│ ┌─────────────────────────────────┐ │
│ │ Token Cache (optional) │ │
│ │ Session Storage │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘// Refresh 5 minutes before expiry
scheduleRefresh(expiresIn - 300);// Refresh on 401 response
if (response.status === 401) {
await refresh();
retry();
}async function apiCall(endpoint) {
// Check token validity
if (isExpired(accessToken)) {
accessToken = await refresh();
}
return fetch(endpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
}Single-page application
Authorization Code + PKCE
High
Mobile application
Authorization Code + PKCE
High
Server-to-server
Client Credentials
High
CLI tool
Device Code
Medium
Smart TV / IoT
Device Code
Medium
First-party mobile app
Password (with caution)
Medium
Web applications with server-side code
You can securely store a client secret
Users need to authenticate
The backend exchanges the code for tokens, keeping secrets secure.
Traditional web applications (Rails, Django, Express)
Server-rendered applications
Applications with session-based auth
Requires backend infrastructure
Client secret must be protected
Full OAuth security guarantees
Single-page applications (React, Vue, Angular)
Mobile applications (iOS, Android)
Desktop applications
Any public client (cannot keep secrets)
React/Vue/Angular SPAs
React Native / Flutter apps
Electron desktop apps
Browser extensions
No client secret required
Proof of possession via PKCE
Recommended for all new public clients
Backend services calling APIs
Microservice communication
Scheduled jobs / cron tasks
No user context needed
Payment processing service
Data synchronization jobs
Inter-service authentication
API integrations
No user involved
No refresh tokens (just request new token)
Client must protect its credentials
Scopes limited to service-level access
Devices without browsers
Limited input capability
Smart TVs, game consoles
CLI applications
Smart TV streaming apps
Game console apps
CLI tools (gh, aws-cli style)
IoT devices
Printers / picture frames
User must have secondary device
Requires polling mechanism
Codes expire (typically 5-15 minutes)
Good UX requires clear instructions
Migrating from legacy systems
Highly trusted first-party apps
When redirect flow is impossible
User enters password in your app
App sees user's credentials
No consent flow
MFA may be bypassed
Never for third-party apps
Auth Code
High
Low
Low
Auth Code
Login + Consent
Medium
Web apps
Add PKCE support to your app
Implement authorization flow
Migrate users gradually
Disable password grant
Add PKCE code exchange
Update authorization request (response_type=code)
Handle code callback
Remove fragment token handling
Don't: Use implicit for new applications Do: Use Authorization Code + PKCE
Don't: Let third-party apps collect passwords Do: Use authorization flow with consent
Don't: Use auth code without PKCE in SPAs/mobile Do: Always use PKCE for public clients
Don't: Issue 24-hour access tokens without refresh Do: Short access tokens + refresh tokens
OAuth 2.0 Concepts - Protocol fundamentals
Security Model - Security considerations
Authorization Code Reference - Implementation details
Web app with backend
Authorization Code
High
User → Your App → Authority → User authenticates → Your Backend → TokensClient generates: code_verifier (random)
Client sends: code_challenge = SHA256(code_verifier)
Authority returns: authorization code
Client proves: code_verifier
Authority validates: SHA256(code_verifier) == code_challenge
Authority issues: tokensYour Service → Authority (client_id + secret) → Token → Your Service → APIDevice → Authority: Request device code
Authority → Device: device_code + user_code + URL
Device → User: "Visit URL, enter code"
User → Browser → Authority: Enter code, authenticate
Device → Authority: Poll for token
Authority → Device: Access tokenApp → User: Enter credentials
App → Authority: username + password
Authority → App: TokensModern admin dashboard
Self-hosted deployment
Your user data stays on your infrastructure:
Full control over data location
Compliance with data residency requirements
No third-party data access
Custom retention policies
No per-user or per-authentication pricing:
Fixed infrastructure costs
Scale without cost surprises
No vendor lock-in
Budget predictability
Complete control over the experience:
Custom login pages
Branded emails
Custom flows
Extended functionality
Your security, your way:
Configure security policies
Custom audit requirements
Integration with existing security tools
Incident response control
All standard grant types:
Authorization Code
Web applications
Authorization Code + PKCE
Mobile / SPA
Client Credentials
Full identity support:
ID tokens for authentication
UserInfo endpoint
Discovery document
JWKS for verification
Production-ready security:
Multi-factor authentication (TOTP)
Account lockout
Password policies
Session management
Comprehensive audit logging
Rate limiting
Modern management interface:
OAuth client management
User administration
Scope configuration
Audit log viewer
System settings
Build authentication for your SaaS product:
Single sign-on across services
User management
Third-party integrations
Secure internal applications:
Employee authentication
Service-to-service auth
API gateway integration
Audit compliance
Power developer ecosystems:
OAuth for third-party apps
API access control
Developer portal integration
Rate limiting per client
Authenticate devices and CLIs:
Device code flow
Machine credentials
Token management
Hosting
Self-hosted
Cloud
Pricing
Infrastructure only
Language
Crystal
Java
Memory footprint
Low
Time to production
Hours
Months
Security review
Done
Configure environment
Set up database
Enable HTTPS
Create admin user
Register clients
See Installation Guide.
Authority implements:
Built with modern, efficient technology:
Language
Crystal
Framework
Azu
Database
Crystal provides:
Performance - Near C speed
Safety - Type-safe, null-safe
Simplicity - Ruby-like syntax
Efficiency - Low memory footprint
Quick Start Tutorial - Get running in 5 minutes
Architecture - System design
Security Model - Security features
Your App → Authority → User Login → Your App
→ API Access# Clone and run
git clone https://github.com/azutoolkit/authority.git
cd authority
docker-compose up -d
# Visit http://localhost:4000Email, username, avatar
email, public_profile
Email, name, profile picture
openid, profile, email
Professional profile data
Apple
name, email
Privacy-focused, email relay option
User's username or email
password
Yes
User's password
scope
Optional
Requested scopes
Token introspection
device_authorization_endpoint
Device flow
Supported grant types
token_endpoint_auth_methods_supported
Client authentication methods
claims_supported
Available user claims
String
First name
family_name
String
Last name
middle_name
String
Middle name
nickname
String
Casual name
preferred_username
String
Username
profile
String
Profile page URL
picture
String
Profile picture URL
website
String
Website URL
gender
String
Gender
birthdate
String
Birthday (YYYY-MM-DD)
zoneinfo
String
Timezone
locale
String
Locale
updated_at
Number
Last updated timestamp
Email verified
Phone verified
Separate request
Purpose
Authentication proof
Additional claims
Freshness
Issued at auth time
Current values
Access token hash
c_hash
Code hash (for hybrid flow)
acr
Authentication context class
amr
Authentication methods used
azp
Authorized party
Last name
email
Email address
email_verified
Email verified
picture
Profile picture URL
Authorization code lifetime (seconds)
DEVICE_CODE_TTL
300
Device code lifetime (seconds)
Session lifetime (days)
IDLE_TIMEOUT_MINUTES
30
Idle timeout
SINGLE_SESSION
false
Allow only one session
Path to private key
SSL_CA
Path to CA certificate
SSL_MODE
SSL mode
Log sources
SMTP server port
SMTP_USER
SMTP username
SMTP_PASSWORD
SMTP password
SMTP_FROM
Sender email
SMTP_FROM_NAME
Authority
Sender name
Rate limit key
RATE_LIMIT_WINDOW
60
Window (seconds)
RATE_LIMIT_MAX
60
Max requests
RATE_LIMIT_WHITELIST
Exempt IPs
Application tagline
COMPANY_NAME
Company name
THEME
dark
Theme (dark/light)
Allowed client IDs
DEFAULT_SCOPES
openid
Default scopes
Prevent password reuse
PASSWORD_EXPIRY_DAYS
0
Password expiration (0=never)
PASSWORD_EXPIRY_WARNING_DAYS
14
Warn before expiry
PASSWORD_EXPIRY_GRACE_DAYS
7
Grace period
REQUIRE_UPPERCASE
true
Require uppercase
REQUIRE_LOWERCASE
true
Require lowercase
REQUIRE_NUMBERS
true
Require numbers
REQUIRE_SPECIAL
false
Require special chars
CHECK_COMMON_PASSWORDS
false
Check against list
COMMON_PASSWORD_LIST
Path to password list
Admin expiration
Require MFA for admins
MFA_GRACE_PERIOD_DAYS
7
Time to set up MFA
Idle timeout
SINGLE_SESSION
false
Only one active session
NOTIFY_NEW_SESSION
false
Email on new login
REVOKE_TOKENS_ON_SESSION_END
true
Revoke tokens on logout
Don't reveal if email exists
Max resets per window
PASSWORD_RESET_RATE_WINDOW
3600
Rate limit window (seconds)
Signing algorithm
Send to syslog
SYSLOG_HOST
Syslog server
SYSLOG_PORT
514
Syslog port
Server-to-server
Device Code
IoT / CLI
Refresh Token
Token renewal
Per user
Data location
Your servers
Their servers
Customization
Full
Limited
Vendor lock-in
None
High
High
Complexity
Simple
Complex
Features
Core OAuth/OIDC
Enterprise IAM
Learning curve
Gentle
Steep
Required
Maintenance
Updates
All on you
Standards compliance
Complete
Variable
PostgreSQL
Caching
Redis
Templates
Crinja
Issues tokens after authorization
Resource Server
Hosts the protected resources (API)
Hours
Refresh Token
Gets new access tokens
Days/Weeks
https://app.example.com/callback?
code=AUTH_CODE_HERE
&state=xyz789POST /token
grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=https://app.example.com/callback
&client_id=abc123
&client_secret=secret123{
"access_token": "eyJhbG...",
"refresh_token": "dGhpc...",
"expires_in": 3600
}GET /api/user
Authorization: Bearer eyJhbG...User claims endpoint
/authorize
Authorization (with OIDC scopes)
/token
Token (returns ID token)
email, email_verified
address
address
phone
phone_number, phone_number_verified
ID token
Question answered
"What can they do?"
"Who are they?"
Scope
Custom scopes
openid, profile, etc.
Grace period for reuse (seconds)
REFRESH_TOKEN_ABSOLUTE_TTL
31536000
Absolute lifetime (1 year)
Signing algorithm
Audience (client ID)
exp
Expiration time
iat
Issued at time
scope
Granted scopes
client_id
Client identifier
Audience
exp
Expiration
iat
Issued at
auth_time
Authentication time
nonce
Request nonce
at_hash
Access token hash
Revoke all tokens on logout
User CRUD, password management
ClientService
OAuth client management
SessionService
Session creation, validation, cleanup
AuditService
Event logging, compliance
PostgreSQL
Cache
Redis
Templates
Crinja (Jinja2)
JWT
crystal-jwt
Who the token is for
exp
When it expires
iat
When it was issued
auth_time
When user authenticated
nonce
Prevents replay attacks
email, email_verified
address
address
phone
phone_number
Authorize API calls
Validate
At login
On each request
Contains
User identity
Authorization grants
JSON
Freshness
Point-in-time
Current values
Limit exposure if stolen
Refresh Token
30 days
User convenience
ID Token
1 hour
Authentication proof
User + admin
Client secrets
Hashed
Never retrievable
Tokens
Database + cache
Service only
State parameter, SameSite cookies
Code interception
PKCE, short code lifetime
Replay attacks
Nonce, one-time codes
Brute force
Lockout, progressive delay
httpOnly, secure
ID
Memory
User info display
30 days
User-Friendly
1 day
90 days
Auth Code + PKCE
Medium
Low
Low
Client Credentials
High
N/A
N/A
Device Code
Medium
Low
Medium
Password
Low
Medium
High
Auth Code + PKCE
Login + Consent
Medium
Mobile/SPA
Client Credentials
None
Low
Services
Device Code
Visit URL + Enter code
High
Limited input
Password
Enter credentials
Low
Legacy