public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:15:52 -05:00
commit a9fa1a0a0f
83 changed files with 20424 additions and 0 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Kratos
KRATOS_DSN=postgres://user:password@localhost:5432/kratos
KRATOS_SECRETS_DEFAULT=your-32-character-secret-here
KRATOS_SECRETS_COOKIE=your-32-character-cookie-secret
# SMTP Configuration
SMTP_CONNECTION_URI=smtp://user:password@smtp.example.com:587
# Frontend
PUBLIC_KRATOS_URL=http://localhost:4433
PUBLIC_KRATOS_BROWSER_URL=http://localhost:4433
# Oathkeeper
OATHKEEPER_MUTATOR_ID_TOKEN_JWKS_URL=file:///etc/jwks.json

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/ory-sdk/
.idea

402
ORY_SDK_MIGRATION_GUIDE.md Normal file
View File

@ -0,0 +1,402 @@
# 🚀 **Ory SDK Migration Guide**
**Fixing CSRF Issues by Using the SDK Properly**
*Created: October 17, 2025*
## 📋 **Problem Analysis**
### **Current Issue**
The frontend has been manually handling forms with `fetch()` and `FormData`, which causes CSRF token mismatches because:
1. **Form submission uses `application/x-www-form-urlencoded`**
2. **Manual CSRF token handling** leads to cookie accumulation
3. **SDK expects `application/json`** with automatic CSRF management
### **Why Admin Works vs Settings Fails**
- **Admin Interface**: Uses `kratosAdminClient.methodName()` → JSON payloads → ✅ Works
- **Settings Forms**: Uses manual `fetch()` → Form-encoded data → ❌ CSRF errors
---
## 🔧 **The Solution: Proper SDK Usage**
### **Core Principle**
Stop using manual `fetch()` calls. Use the Ory SDK methods which handle CSRF automatically.
---
## 📚 **SDK Method Reference**
### **Available Methods**
```typescript
// Flow Creation (usually browser redirects)
kratosClient.createBrowserLoginFlow()
kratosClient.createBrowserRegistrationFlow()
kratosClient.createBrowserSettingsFlow()
kratosClient.createBrowserRecoveryFlow()
// Flow Data Fetching
kratosClient.getLoginFlow({ id: flowId })
kratosClient.getRegistrationFlow({ id: flowId })
kratosClient.getSettingsFlow({ id: flowId })
kratosClient.getRecoveryFlow({ id: flowId })
// Flow Submission (the key ones!)
kratosClient.updateLoginFlow({ flow: flowId, updateLoginFlowBody: data })
kratosClient.updateRegistrationFlow({ flow: flowId, updateRegistrationFlowBody: data })
kratosClient.updateSettingsFlow({ flow: flowId, updateSettingsFlowBody: data })
kratosClient.updateRecoveryFlow({ flow: flowId, updateRecoveryFlowBody: data })
```
---
## 🏗️ **Migration Pattern**
### **1. Form Data Conversion**
```typescript
// Helper function to convert FormData to JSON
const formDataToJson = (formData: FormData): Record<string, any> => {
const json: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
// Skip CSRF token - SDK handles this automatically
if (key === 'csrf_token') continue;
// Handle method field (used by Kratos to determine which action to take)
if (key === 'method') {
json.method = value;
continue;
}
// Handle nested object notation (e.g., traits.email, traits.name.first)
if (key.includes('.')) {
setNestedProperty(json, key, value);
} else {
json[key] = value;
}
}
return json;
};
// Helper for nested properties
const setNestedProperty = (obj: any, path: string, value: any) => {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current)) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
};
```
### **2. Settings Form Handler (Before → After)**
**❌ BEFORE (Manual fetch - causes CSRF issues)**
```typescript
// OLD WAY - DON'T USE
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const response = await fetch(flow.ui.action, {
method: 'POST',
body: formData, // ← Form-encoded data
credentials: 'include',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
// ... handle response
}
```
**✅ AFTER (SDK method - handles CSRF automatically)**
```typescript
// NEW WAY - USE THIS
import { kratosClient } from '$lib/kratos';
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// Convert form data to JSON
const updateBody = formDataToJson(formData);
try {
// Use SDK method - handles CSRF automatically via JSON + cookies
const { data } = await kratosClient.updateSettingsFlow({
flow: flowId,
updateSettingsFlowBody: updateBody
});
// Handle success
if (data.redirect_browser_to) {
window.location.href = data.redirect_browser_to;
} else {
// Show success message or reload
window.location.reload();
}
} catch (error: any) {
console.error('Settings update failed:', error);
// Handle specific errors
if (error.response?.status === 410) {
// Flow expired, restart
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
} else if (error.response?.data?.ui) {
// Update flow with validation errors
flow = error.response.data;
}
}
}
```
---
## 📝 **Implementation Steps**
### **Step 1: Update FlowForm Component**
```typescript
// src/lib/components/FlowForm.svelte
<script lang="ts">
import { kratosClient } from '$lib/kratos';
import type { LoginFlow, RegistrationFlow, SettingsFlow, RecoveryFlow, VerificationFlow } from '@ory/client';
type Flow = LoginFlow | RegistrationFlow | SettingsFlow | RecoveryFlow | VerificationFlow;
let { flow, groups = ['default', 'password', 'profile', 'totp', 'webauthn', 'code', 'link'] }: {
flow: Flow;
groups?: string[]
} = $props();
const nodes = $derived(filterNodesByGroups(flow.ui.nodes, ...groups));
const messages = $derived(flow.ui.messages || []);
const formMethod = $derived((flow.ui.method?.toLowerCase() || 'post') as 'get' | 'post');
// Helper function to convert FormData to JSON
const formDataToJson = (formData: FormData): Record<string, any> => {
const json: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
if (key === 'csrf_token') continue; // SDK handles this
if (key.includes('.')) {
setNestedProperty(json, key, value);
} else {
json[key] = value;
}
}
return json;
};
const setNestedProperty = (obj: any, path: string, value: any) => {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current)) current[key] = {};
current = current[key];
}
current[keys[keys.length - 1]] = value;
};
// SDK-based form submission
async function handleSubmit(event: Event) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const updateBody = formDataToJson(formData);
try {
let response;
// Use appropriate SDK method based on flow type
switch (flow.type) {
case 'login':
response = await kratosClient.updateLoginFlow({
flow: flow.id,
updateLoginFlowBody: updateBody
});
break;
case 'registration':
response = await kratosClient.updateRegistrationFlow({
flow: flow.id,
updateRegistrationFlowBody: updateBody
});
break;
case 'settings':
response = await kratosClient.updateSettingsFlow({
flow: flow.id,
updateSettingsFlowBody: updateBody
});
break;
case 'recovery':
response = await kratosClient.updateRecoveryFlow({
flow: flow.id,
updateRecoveryFlowBody: updateBody
});
break;
case 'verification':
response = await kratosClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: updateBody
});
break;
default:
throw new Error(`Unsupported flow type: ${flow.type}`);
}
// Handle success response
if (response.data.redirect_browser_to) {
window.location.href = response.data.redirect_browser_to;
} else {
// For settings, might want to show success message
if (flow.type === 'settings') {
window.location.href = window.location.pathname + '?updated=true';
} else {
window.location.reload();
}
}
} catch (error: any) {
console.error('Flow submission failed:', error);
if (error.response?.status === 410) {
// Flow expired, restart appropriate flow
const flowType = flow.type;
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/${flowType}/browser`;
} else if (error.response?.data?.ui) {
// Update with validation errors
flow = error.response.data;
}
}
}
</script>
<!-- Remove manual onsubmit handlers, use SDK-based submission -->
<form action={flow.ui.action} method={formMethod} onsubmit={handleSubmit}>
{#each nodes as node (node.attributes)}
<FormField {node} />
{/each}
</form>
```
### **Step 2: Update Individual Page Components**
```typescript
// src/routes/settings/+page.svelte
<script lang="ts">
import { kratosClient } from '$lib/kratos';
import FlowForm from '$lib/components/FlowForm.svelte';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { SettingsFlow } from '@ory/client';
let flow = $state<SettingsFlow | null>(null);
const isUpdated = $derived(page.url.searchParams.has('updated'));
onMount(async () => {
const flowId = page.url.searchParams.get('flow');
if (!flowId) {
// Redirect to create flow
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
return;
}
try {
// Use SDK method to fetch flow
const { data } = await kratosClient.getSettingsFlow({ id: flowId });
flow = data;
} catch (error) {
console.error('Failed to fetch settings flow:', error);
// Restart flow on error
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
}
});
</script>
<!-- Rest of component remains the same -->
{#if flow}
<FlowForm {flow} groups={['profile']} />
<FlowForm {flow} groups={['password']} />
<FlowForm {flow} groups={['totp', 'webauthn']} />
{/if}
```
---
## 🎯 **Key Benefits**
### **✅ What This Fixes**
1. **No more CSRF errors** - SDK handles tokens automatically
2. **No cookie accumulation** - Proper JSON-based API usage
3. **Better error handling** - Structured error responses
4. **Type safety** - Full TypeScript support
5. **Consistency** - Same pattern as admin interface
### **📊 Comparison**
| Aspect | Manual Fetch (Old) | SDK Methods (New) |
|--------|-------------------|-------------------|
| **Content-Type** | `application/x-www-form-urlencoded` | `application/json` |
| **CSRF Handling** | Manual form fields | Automatic via cookies |
| **Error Handling** | Manual response parsing | Structured exceptions |
| **Type Safety** | None | Full TypeScript |
| **Cookie Management** | Manual/broken | Automatic |
---
## 🚨 **Migration Checklist**
- [ ] **Remove `clearCsrfCookies()` calls** - No longer needed
- [ ] **Update FlowForm component** - Use SDK methods instead of fetch
- [ ] **Update all page components** - Use SDK for flow fetching
- [ ] **Remove manual CSRF handling** - SDK does this automatically
- [ ] **Test all flows** - Login, Registration, Settings, Recovery
- [ ] **Remove unused utilities** - Clean up old cookie management code
---
## 🔄 **Testing Strategy**
### **Before Deployment**
1. **Clear all browser cookies** for your domain
2. **Test complete user journey**:
- Registration → Login → Settings update → Logout
3. **Verify no CSRF errors** in browser console
4. **Check network tab** - Should see JSON payloads, not form data
### **Production Validation**
1. **Monitor error logs** for any remaining CSRF issues
2. **Test across different browsers** (Chrome, Firefox, Safari)
3. **Verify admin interface** still works (should be unchanged)
---
## 📚 **Additional Resources**
- [Ory Kratos Self-Service Flows](https://www.ory.sh/docs/kratos/self-service)
- [TypeScript SDK Documentation](https://github.com/ory/sdk/tree/master/clients/client/typescript)
- [Flow UI Integration Guide](https://www.ory.sh/docs/kratos/self-service/flows/user-settings)
---
*This migration will eliminate your CSRF issues completely by using the Ory SDK as intended. The SDK's JSON-based approach with automatic CSRF handling is the official, supported pattern.*

597
PRODUCTION-DEPLOYMENT.md Normal file
View File

@ -0,0 +1,597 @@
# Production Deployment Checklist
## Architecture Overview
**Production Domains:**
- Frontend: `https://account.example.com`
- Oathkeeper Proxy: `https://auth.example.com` (port 4455)
- Django API: `https://api.example.com`
- Kratos: Internal only (ports 4433/4434)
- Oathkeeper API: Internal only (port 4456)
**All services run on the same VM**, so internal communication uses localhost/docker network.
---
## Pre-Deployment Checklist
### 1. Security Hardening
#### Kratos Secrets
```bash
# Generate new secrets for production
openssl rand -hex 16 # SECRETS_DEFAULT
openssl rand -hex 16 # SECRETS_COOKIE
openssl rand -hex 16 # SECRETS_CIPHER
```
Update in `nexus-5-auth-kratos/.env.production`:
- [ ] `SECRETS_DEFAULT` - New random value
- [ ] `SECRETS_COOKIE` - New random value
- [ ] `SECRETS_CIPHER` - New random value
#### Database Passwords
- [ ] Change `POSTGRES_PASSWORD` in `nexus-5-auth-kratos/.env.production`
- [ ] Update `KRATOS_DSN` with the new URL-encoded password
#### SMTP Configuration
- [ ] Verify SMTP credentials in `nexus-5-auth-kratos/config/kratos.yml` (line 128)
- [ ] Consider using environment variable instead of hardcoded value
### 2. SSL/TLS Configuration
#### Oathkeeper (https://auth.example.com)
- [ ] Configure reverse proxy (nginx/caddy) for SSL termination
- [ ] Install SSL certificate for `auth.example.com`
- [ ] Configure proxy to forward to `localhost:4455`
#### Frontend (https://account.example.com)
- [ ] Configure reverse proxy for SSL termination
- [ ] Install SSL certificate for `account.example.com`
- [ ] Configure proxy to forward to SvelteKit server (typically port 3000 or 5173)
#### CORS Configuration
Verify Oathkeeper CORS is configured (`nexus-5-auth-oathkeeper/config/oathkeeper.yml`):
- [x] `https://account.example.com` in allowed_origins
- [x] `https://auth.example.com` in allowed_origins
- [x] `https://api.example.com` in allowed_origins
- [x] `allow_credentials: true`
### 3. Environment Files
#### Replace .env files with production versions:
```bash
# Kratos
cp nexus-5-auth-kratos/.env.production nexus-5-auth-kratos/.env
# Oathkeeper
cp nexus-5-auth-oathkeeper/.env.production nexus-5-auth-oathkeeper/.env
# Frontend
cp nexus-5-auth-frontend/.env.production nexus-5-auth-frontend/.env
```
#### Verify all environment variables:
- [ ] `nexus-5-auth-kratos/.env`
- [ ] `nexus-5-auth-oathkeeper/.env`
- [ ] `nexus-5-auth-frontend/.env`
---
## Deployment Steps
### 1. Database Setup
```bash
cd nexus-5-auth-kratos
# Start PostgreSQL
docker compose up -d postgres
# Wait for PostgreSQL to be ready
docker compose logs -f postgres
# Wait for "database system is ready to accept connections"
# Run Kratos migrations
docker compose run --rm kratos migrate sql -e --yes
```
### 2. Deploy Kratos
```bash
cd nexus-5-auth-kratos
# Build and start Kratos
docker compose up -d kratos
# Verify it's running
docker compose ps
docker compose logs kratos
# Test health endpoint
curl http://localhost:4433/health/ready
```
**Expected response:**
```json
{"status": "ok"}
```
### 3. Deploy Oathkeeper
```bash
cd nexus-5-auth-oathkeeper
# Rebuild with updated config
docker compose build oathkeeper
# Start Oathkeeper
docker compose up -d oathkeeper
# Verify it's running
docker compose ps
docker compose logs oathkeeper
# Test health endpoint
curl http://localhost:4456/health/ready
```
**Expected response:**
```json
{"status": "ok"}
```
### 4. Test Access Rules
```bash
# List all configured rules
curl http://localhost:4456/rules | jq .
# Verify rule count (should be 9 rules)
curl -s http://localhost:4456/rules | jq 'length'
```
**Expected rules:**
1. `kratos:self-service`
2. `kratos:admin:identities`
3. `kratos:admin:recovery`
4. `kratos:admin:courier`
5. `kratos:admin:sessions`
6. `kratos:sessions:api`
7. `django:api:public`
8. `django:api:protected`
9. `django:api:v1`
### 5. Deploy Frontend
#### Option A: Docker Deployment (Recommended)
```bash
cd nexus-5-auth-frontend
# Copy production environment
cp .env.production .env
# Ensure ory-network exists
docker network create ory-network 2>/dev/null || true
# Build and start
docker compose up -d
# Verify it's running
docker compose ps
docker compose logs frontend
# Test health endpoint
curl http://localhost:3000/
```
**Expected response:** HTML page content
#### Option B: PM2 Deployment
```bash
cd nexus-5-auth-frontend
# Install dependencies
npm install
# Copy production environment
cp .env.production .env
# Build for production
npm run build
# Deploy with PM2
pm2 start npm --name "nexus-auth-frontend" -- start
pm2 save
```
#### Option C: Direct Node Deployment
```bash
cd nexus-5-auth-frontend
# Install dependencies
npm install
# Copy production environment
cp .env.production .env
# Build for production
npm run build
# Start with node
node build
```
### 6. Configure Reverse Proxy
#### Example Nginx Configuration
**File: `/etc/nginx/sites-available/auth.example.com`**
```nginx
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /path/to/ssl/cert.pem;
ssl_certificate_key /path/to/ssl/key.pem;
location / {
proxy_pass http://localhost:4455;
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;
# WebSocket support (if needed)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**File: `/etc/nginx/sites-available/account.example.com`**
```nginx
server {
listen 443 ssl http2;
server_name account.example.com;
ssl_certificate /path/to/ssl/cert.pem;
ssl_certificate_key /path/to/ssl/key.pem;
location / {
proxy_pass http://localhost:3000; # Adjust to your SvelteKit port
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;
# WebSocket support for HMR (disable in production)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
Enable sites and reload nginx:
```bash
sudo ln -s /etc/nginx/sites-available/auth.example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/account.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## Post-Deployment Testing
### 1. Health Checks
```bash
# Kratos public API
curl https://auth.example.com/health/ready
# Kratos admin API (through Oathkeeper - requires auth)
curl https://auth.example.com/admin/identities
# Oathkeeper API (internal)
curl http://localhost:4456/health/ready
```
### 2. Frontend Testing
Visit `https://account.example.com` and test:
- [ ] Registration flow
- [ ] Login flow
- [ ] Email verification
- [ ] Password recovery
- [ ] Settings page
- [ ] Admin dashboard (identity management)
- [ ] Session management
- [ ] Logout
### 3. WebAuthn Testing
- [ ] Register with passkey/security key
- [ ] Login with passkey/security key
- [ ] TOTP (authenticator app) setup
- [ ] TOTP login
### 4. API Testing
Test Django integration (once you have an authenticated session):
```bash
# Public API (no auth)
curl https://auth.example.com/api/public/
# Protected API (with session cookie)
curl -b cookies.txt https://auth.example.com/api/protected/
# Bearer token API
curl -H "Authorization: Bearer YOUR_TOKEN" https://auth.example.com/api/v1/
```
### 5. Verify Headers Forwarded to Django
Create a test identity and check headers received by Django:
**Expected headers from Oathkeeper:**
```
X-User-ID: <kratos_identity_id>
X-User-Email: user@example.com
X-User-First-Name: John
X-User-Last-Name: Doe
X-User-Phone: +1234567890
X-User-Profile-Type: team|customer
X-Django-Profile-ID: <uuid>
X-Customer-ID: <uuid>
```
---
## Django Backend Integration
### 1. Update Django Settings
Add trusted headers and CORS configuration:
```python
# settings.py
# Oathkeeper proxy headers
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# CORS settings
CORS_ALLOWED_ORIGINS = [
"https://account.example.com",
"https://auth.example.com",
]
CORS_ALLOW_CREDENTIALS = True
# Session/Cookie settings
SESSION_COOKIE_DOMAIN = '.example.com'
CSRF_COOKIE_DOMAIN = '.example.com'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
```
### 2. Create Authentication Middleware
```python
# middleware/kratos_auth.py
class KratosAuthMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Extract Kratos identity from headers
user_id = request.META.get('HTTP_X_USER_ID')
user_email = request.META.get('HTTP_X_USER_EMAIL')
first_name = request.META.get('HTTP_X_USER_FIRST_NAME')
last_name = request.META.get('HTTP_X_USER_LAST_NAME')
phone = request.META.get('HTTP_X_USER_PHONE')
profile_type = request.META.get('HTTP_X_USER_PROFILE_TYPE')
django_profile_id = request.META.get('HTTP_X_DJANGO_PROFILE_ID')
customer_id = request.META.get('HTTP_X_CUSTOMER_ID')
if user_id and user_email:
# Look up or create user based on Kratos identity
# Attach to request.user or request.kratos_user
pass
response = self.get_response(request)
return response
```
Add to `MIDDLEWARE` in settings.py:
```python
MIDDLEWARE = [
# ... other middleware
'your_app.middleware.kratos_auth.KratosAuthMiddleware',
]
```
### 3. Sync Identity on Registration
When a user registers in Kratos, sync to Django:
**Option A: Webhook (recommended)**
- Configure Kratos webhook to call Django API on identity creation
- Django creates corresponding TeamProfile/CustomerProfile
- Returns django_profile_id to be stored in Kratos metadata_public
**Option B: Poll/Manual Sync**
- Periodic task to sync new Kratos identities to Django
- Less real-time but simpler to implement
---
## Monitoring & Logging
### 1. Log Aggregation
Collect logs from all services:
```bash
# Kratos logs
docker compose -f nexus-5-auth-kratos/docker-compose.yml logs -f kratos
# Oathkeeper logs
docker compose -f nexus-5-auth-oathkeeper/docker-compose.yml logs -f oathkeeper
# Frontend logs (if using PM2)
pm2 logs nexus-auth-frontend
```
### 2. Metrics to Monitor
- [ ] Kratos health endpoint: `GET /health/ready`
- [ ] Oathkeeper health endpoint: `GET /health/ready`
- [ ] Database connection pool usage
- [ ] Session count
- [ ] Identity count
- [ ] Failed login attempts
- [ ] Email delivery failures
### 3. Set Log Levels
**Production log levels:**
- Kratos: `LOG_LEVEL=info`
- Oathkeeper: `log.level=info`
- Frontend: Configure in SvelteKit
---
## Backup & Recovery
### 1. Database Backups
```bash
# Backup Kratos database
docker compose -f nexus-5-auth-kratos/docker-compose.yml exec postgres \
pg_dump -U kratos kratos > kratos-backup-$(date +%Y%m%d).sql
# Restore
docker compose -f nexus-5-auth-kratos/docker-compose.yml exec -T postgres \
psql -U kratos kratos < kratos-backup-20251014.sql
```
### 2. Configuration Backups
- [ ] Backup `nexus-5-auth-kratos/config/`
- [ ] Backup `nexus-5-auth-oathkeeper/config/`
- [ ] Backup `.env` files (encrypted storage!)
- [ ] Backup JWKS keys: `nexus-5-auth-oathkeeper/config/id_token.jwks.json`
---
## Rollback Plan
If issues occur in production:
### 1. Quick Rollback
```bash
# Stop services
docker compose down
# Revert to previous .env
git checkout HEAD~1 nexus-5-auth-*/
# Restart with old config
docker compose up -d
```
### 2. Database Rollback
```bash
# Restore from backup
docker compose exec -T postgres psql -U kratos kratos < kratos-backup-YYYYMMDD.sql
```
---
## Security Checklist
- [ ] All secrets rotated for production
- [ ] SSL certificates installed and valid
- [ ] HTTPS enforced on all domains
- [ ] Database passwords strong and unique
- [ ] SMTP credentials secured
- [ ] Cookie domain set to `.example.com`
- [ ] Session cookies marked as Secure
- [ ] CORS properly configured
- [ ] Admin API requires authentication
- [ ] Rate limiting configured (if needed)
- [ ] Firewall rules: Only 443/80 exposed publicly
- [ ] Internal ports (4433, 4434, 4456, 5432) blocked from external access
---
## Support & Troubleshooting
### Common Issues
**Issue: "Cookie not being set"**
- Check `session.cookie.domain` in kratos.yml is `example.com`
- Verify HTTPS is working
- Check browser dev tools > Application > Cookies
**Issue: "CORS errors"**
- Verify Oathkeeper CORS config includes all domains
- Check `allow_credentials: true`
- Verify Origin header matches allowed_origins
**Issue: "Redirect loop"**
- Check `preserve_host` settings in access rules
- Verify Kratos `allowed_return_urls` includes production domains
**Issue: "WebAuthn not working"**
- Verify `webauthn.config.rp.id` is `example.com`
- Check `webauthn.config.rp.origins` includes production URLs
- Ensure HTTPS is working (WebAuthn requires secure context)
### Debug Commands
```bash
# Check Oathkeeper rules
curl http://localhost:4456/rules | jq .
# Check Kratos sessions
curl -H "Cookie: ory_kratos_session=..." http://localhost:4433/sessions/whoami
# Test Oathkeeper decision API
curl -H "Cookie: ory_kratos_session=..." http://localhost:4455/decisions/admin/identities
# View Kratos configuration
docker compose exec kratos cat /etc/kratos/kratos.yml
```
---
## Production Deployment Complete! 🎉
Once all checklist items are complete, your Nexus 5 Auth system is production-ready with:
✅ Ory Kratos for identity management
✅ Ory Oathkeeper for authentication & authorization
✅ SvelteKit frontend with admin dashboard
✅ Full Django integration with custom headers
✅ Secure session management across subdomains
✅ WebAuthn/TOTP support
✅ Email verification & recovery
✅ Complete API endpoint coverage
**Next Steps:**
1. Monitor logs for the first 24 hours
2. Test all user flows in production
3. Set up automated backups
4. Configure monitoring/alerting
5. Document any environment-specific configurations

273
README.md Normal file
View File

@ -0,0 +1,273 @@
# Nexus 5 Auth
Enterprise authentication infrastructure for the Nexus 5 platform using Ory Kratos for identity management and Ory Oathkeeper for API gateway/authorization.
## Overview
This repository contains the authentication stack configuration for Nexus 5:
- **nexus-5-auth-kratos**: Ory Kratos identity server configuration
- **nexus-5-auth-oathkeeper**: Ory Oathkeeper API gateway configuration
- **nexus-5-auth-frontend**: SvelteKit-based authentication UI
## Why Ory?
### Improvements Over Previous Auth Approaches
| Feature | Nexus 1-3 (JWT) | Nexus 4 (Rust JWT) | Nexus 5 (Ory) |
|---------|-----------------|--------------------| --------------|
| **Identity Management** | Django User model | Custom User entity | Dedicated identity server |
| **Session Management** | Stateless JWT | Stateless JWT | Server-side sessions |
| **MFA Support** | None | None | Built-in TOTP/WebAuthn |
| **Password Recovery** | Custom implementation | Custom implementation | Built-in flows |
| **Email Verification** | Custom implementation | None | Built-in flows |
| **Admin UI** | Django Admin | None | Full admin capabilities |
| **API Protection** | Middleware checks | Middleware checks | Zero-trust gateway |
### Key Benefits
1. **Security**: Battle-tested, open-source identity infrastructure
2. **Separation of Concerns**: Auth logic separate from business logic
3. **Scalability**: Stateless gateway, horizontally scalable identity server
4. **Standards Compliance**: OAuth2, OpenID Connect ready
5. **Self-Hosted**: Full control over identity data
## Architecture
```
┌─────────────────┐
│ Auth Frontend │
│ (SvelteKit) │
└────────┬────────┘
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Client │───▶│ Oathkeeper │───▶│ Kratos │
│ (Browser) │ │ (API Gateway) │ │ (Identity Mgmt) │
└─────────────┘ └────────┬────────┘ └─────────────────┘
┌─────────────────┐
│ Nexus 5 API │
│ (Django) │
└─────────────────┘
```
## Components
### Ory Kratos (`nexus-5-auth-kratos/`)
Identity management server handling:
- User registration and login
- Password recovery
- Email verification
- Profile management
- Session management
**Key Files:**
- `config/kratos.yml` - Main Kratos configuration
- `config/identity.schema.json` - User identity schema
- `courier-templates/` - Email templates
### Ory Oathkeeper (`nexus-5-auth-oathkeeper/`)
API gateway providing:
- Request authentication
- JWT token injection for backend services
- Access rule management
- Zero-trust API protection
**Key Files:**
- `config/oathkeeper.yml` - Main Oathkeeper configuration
- `config/access-rules/*.yml` - Route-specific access rules
### Auth Frontend (`nexus-5-auth-frontend/`)
SvelteKit application providing:
- Login/Registration forms
- Password recovery flow
- Email verification flow
- Profile settings
- Admin identity management
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Node.js 18+ (for frontend development)
### Development Setup
```bash
# Start Kratos
cd nexus-5-auth-kratos
docker-compose up -d
# Start Oathkeeper
cd ../nexus-5-auth-oathkeeper
docker-compose up -d
# Start Frontend
cd ../nexus-5-auth-frontend
npm install
npm run dev
```
### Configuration
#### Kratos Configuration
Edit `nexus-5-auth-kratos/config/kratos.yml`:
```yaml
serve:
public:
base_url: https://auth.example.com
admin:
base_url: http://kratos:4434
selfservice:
default_browser_return_url: https://app.example.com
courier:
smtp:
connection_uri: smtp://user:pass@smtp.example.com:587
```
#### Oathkeeper Configuration
Edit `nexus-5-auth-oathkeeper/config/oathkeeper.yml`:
```yaml
serve:
proxy:
port: 4455
api:
port: 4456
authenticators:
cookie_session:
config:
check_session_url: http://kratos:4433/sessions/whoami
authorizers:
allow:
enabled: true
mutators:
header:
enabled: true
config:
headers:
X-User-Id: "{{ print .Subject }}"
```
## Identity Schema
The identity schema defines user attributes:
```json
{
"$id": "https://example.com/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "User",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email"
},
"name": {
"type": "object",
"properties": {
"first": { "type": "string" },
"last": { "type": "string" }
}
}
},
"required": ["email"]
}
}
}
```
## Access Rules
Access rules define how requests are authenticated and authorized:
```yaml
# Django API protected routes
- id: "django:graphql"
upstream:
url: "http://nexus-api:8000"
match:
url: "https://api.example.com/graphql/<**>"
methods: ["POST", "OPTIONS"]
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
```
## Email Templates
Customize email templates in `nexus-5-auth-kratos/courier-templates/`:
- `verification_code_valid.email.*.gotmpl` - Email verification
- `recovery_code_valid.email.*.gotmpl` - Password recovery
- `recovery_valid.email.*.gotmpl` - Recovery link
## Frontend Routes
The auth frontend provides these routes:
- `/login` - User login
- `/registration` - New user registration
- `/recovery` - Password recovery
- `/verification` - Email verification
- `/settings` - Profile settings
- `/admin` - Identity administration
## Production Deployment
### Security Checklist
1. Generate strong secrets for Kratos and Oathkeeper
2. Use HTTPS for all endpoints
3. Configure secure cookie settings
4. Set up proper CORS origins
5. Enable rate limiting
6. Configure email delivery (SMTP or API)
7. Set up database backups for Kratos
### Environment Variables
```bash
# Kratos
KRATOS_DSN=postgres://user:pass@host:5432/kratos
KRATOS_SECRETS_DEFAULT=your-32-char-secret
KRATOS_SECRETS_COOKIE=your-32-char-cookie-secret
# Oathkeeper
OATHKEEPER_MUTATOR_ID_TOKEN_JWKS_URL=file:///etc/jwks.json
# Frontend
PUBLIC_KRATOS_URL=https://auth.example.com
```
## Related Repositories
- **nexus-5**: Main Django API server
- **nexus-5-frontend-***: Application frontends
- **nexus-5-emailer**: Email microservice
- **nexus-5-scheduler**: Calendar integration
## License
MIT License - See LICENSE file for details.

View File

@ -0,0 +1,32 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Build outputs
.svelte-kit
build
dist
# Misc
.DS_Store
*.pem
.env.local
.env.development.local
.env.test.local
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode
.idea
# Git
.git
.gitignore

24
nexus-5-auth-frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/.env.example

View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

View File

@ -0,0 +1,63 @@
# ====================================
# Build Stage
# ====================================
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy source code and configuration
COPY . .
# Copy environment file (will use production .env)
COPY .env .env
# Build the SvelteKit application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# ====================================
# Production Stage
# ====================================
FROM node:20-alpine
# Install curl for health checks
RUN apk add --no-cache curl
# Set working directory
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.env ./.env
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S sveltekit -u 1001 && \
chown -R sveltekit:nodejs /app
# Switch to non-root user
USER sveltekit
# Expose port 3000
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
# Set environment variable for production
ENV NODE_ENV=production
# Start the application
CMD ["node", "build"]

View File

@ -0,0 +1,28 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-auth-frontend
restart: unless-stopped
ports:
- '${FRONTEND_PORT:-3000}:3000'
environment:
- NODE_ENV=production
- PUBLIC_KRATOS_URL=${PUBLIC_KRATOS_URL}
- KRATOS_SERVER_URL=${KRATOS_SERVER_URL}
- ORIGIN=${ORIGIN}
- ADMIN_USER_ID=${ADMIN_USER_ID}
networks:
- ory-network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
networks:
ory-network:
external: true
name: ory-network

View File

@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

4925
nexus-5-auth-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"name": "nexus-5-auth-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@sveltejs/adapter-node": "^5.3.2",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.18",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7"
},
"dependencies": {
"@ory/client": "^1.22.5",
"axios": "^1.12.2",
"flowbite-svelte": "^1.17.4"
}
}

View File

@ -0,0 +1,314 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* ============================================
THEME COLOR SYSTEM
============================================
Primary: Blue
Secondary: Green
Primary Accent: Orange
Secondary Accent: Purple
Alert/Error: Red
Warning: Yellow
Success: Green (distinct from secondary)
============================================ */
@theme {
/* Primary - Blue (muted/professional) */
--color-primary-50: #f0f6fc;
--color-primary-100: #dbe8f7;
--color-primary-200: #bdd4f0;
--color-primary-300: #8fb8e5;
--color-primary-400: #5a94d6;
--color-primary-500: #3b78c4;
--color-primary-600: #2d5fa6;
--color-primary-700: #274d87;
--color-primary-800: #254270;
--color-primary-900: #23395e;
--color-primary-950: #18253f;
/* Secondary - Green (muted/professional) */
--color-secondary-50: #f2f8f4;
--color-secondary-100: #e0efe4;
--color-secondary-200: #c3dfcc;
--color-secondary-300: #96c7a6;
--color-secondary-400: #65a97b;
--color-secondary-500: #458c5e;
--color-secondary-600: #33714a;
--color-secondary-700: #2a5b3d;
--color-secondary-800: #244933;
--color-secondary-900: #1f3c2b;
--color-secondary-950: #102118;
/* Accent Primary - Orange (muted/professional) */
--color-accent-50: #fdf6f0;
--color-accent-100: #fbe9db;
--color-accent-200: #f6d0b6;
--color-accent-300: #f0b088;
--color-accent-400: #e88958;
--color-accent-500: #e16a36;
--color-accent-600: #d2522b;
--color-accent-700: #ae3f26;
--color-accent-800: #8b3425;
--color-accent-900: #712e22;
--color-accent-950: #3d1510;
/* Accent Secondary - Purple (muted/professional) */
--color-accent2-50: #f6f4fb;
--color-accent2-100: #ede9f7;
--color-accent2-200: #ddd5f0;
--color-accent2-300: #c5b6e4;
--color-accent2-400: #a78fd4;
--color-accent2-500: #8b6bc2;
--color-accent2-600: #7652ab;
--color-accent2-700: #634391;
--color-accent2-800: #533978;
--color-accent2-900: #463162;
--color-accent2-950: #2c1c42;
/* Error/Alert - Red (muted/professional) */
--color-error-50: #fdf3f3;
--color-error-100: #fce4e4;
--color-error-200: #fbcdcd;
--color-error-300: #f6a8a8;
--color-error-400: #ee7676;
--color-error-500: #e14a4a;
--color-error-600: #cd2d2d;
--color-error-700: #ac2323;
--color-error-800: #8e2121;
--color-error-900: #772222;
--color-error-950: #400d0d;
/* Warning - Yellow (muted/professional) */
--color-warning-50: #fdfaeb;
--color-warning-100: #faf2c9;
--color-warning-200: #f5e394;
--color-warning-300: #efd05b;
--color-warning-400: #e8bb30;
--color-warning-500: #d8a01d;
--color-warning-600: #ba7c16;
--color-warning-700: #955916;
--color-warning-800: #7b4619;
--color-warning-900: #693a1a;
--color-warning-950: #3d1e0a;
/* Success - Green (distinct from secondary, muted) */
--color-success-50: #f0fdf2;
--color-success-100: #dcfce2;
--color-success-200: #bbf7c6;
--color-success-300: #86ef9b;
--color-success-400: #4ade6a;
--color-success-500: #22c546;
--color-success-600: #16a336;
--color-success-700: #16802e;
--color-success-800: #176528;
--color-success-900: #155324;
--color-success-950: #052e10;
/* Neutral/Surface colors for theming */
--color-surface-50: #f8fafc;
--color-surface-100: #f1f5f9;
--color-surface-200: #e2e8f0;
--color-surface-300: #cbd5e1;
--color-surface-400: #94a3b8;
--color-surface-500: #64748b;
--color-surface-600: #475569;
--color-surface-700: #334155;
--color-surface-800: #1e293b;
--color-surface-900: #0f172a;
--color-surface-950: #020617;
}
/* ============================================
LIGHT THEME (default)
============================================ */
:root {
color-scheme: light;
/* Background colors - subtle blue tint for softer appearance */
--theme-bg: var(--color-primary-50);
--theme-bg-secondary: #e8f0f8;
--theme-bg-tertiary: var(--color-primary-100);
/* Text colors */
--theme-text: var(--color-surface-900);
--theme-text-secondary: var(--color-surface-600);
--theme-text-muted: var(--color-surface-400);
/* Border colors */
--theme-border: var(--color-surface-200);
--theme-border-hover: var(--color-surface-300);
/* Interactive states */
--theme-hover: var(--color-primary-100);
--theme-active: var(--color-primary-200);
/* Shadows */
--theme-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--theme-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Card/Panel backgrounds - subtle blue tint to match overall theme */
--theme-card: #f5f8fc;
--theme-card-hover: #edf2f9;
}
/* ============================================
DARK THEME
============================================ */
.dark {
color-scheme: dark;
/* Background colors */
--theme-bg: var(--color-surface-900);
--theme-bg-secondary: var(--color-surface-800);
--theme-bg-tertiary: var(--color-surface-700);
/* Text colors */
--theme-text: var(--color-surface-50);
--theme-text-secondary: var(--color-surface-300);
--theme-text-muted: var(--color-surface-500);
/* Border colors */
--theme-border: var(--color-surface-700);
--theme-border-hover: var(--color-surface-600);
/* Interactive states */
--theme-hover: var(--color-surface-800);
--theme-active: var(--color-surface-700);
/* Shadows (more subtle in dark mode) */
--theme-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--theme-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--theme-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
/* Card/Panel backgrounds */
--theme-card: var(--color-surface-800);
--theme-card-hover: var(--color-surface-700);
}
/* ============================================
BASE STYLES
============================================ */
html {
background-color: var(--theme-bg);
color: var(--theme-text);
transition:
background-color 0.2s ease,
color 0.2s ease;
}
body {
background-color: var(--theme-bg);
min-height: 100vh;
}
/* ============================================
UTILITY CLASSES
============================================ */
@utility bg-theme {
background-color: var(--theme-bg);
}
@utility bg-theme-secondary {
background-color: var(--theme-bg-secondary);
}
@utility bg-theme-tertiary {
background-color: var(--theme-bg-tertiary);
}
@utility bg-theme-card {
background-color: var(--theme-card);
}
@utility text-theme {
color: var(--theme-text);
}
@utility text-theme-secondary {
color: var(--theme-text-secondary);
}
@utility text-theme-muted {
color: var(--theme-text-muted);
}
@utility border-theme {
border-color: var(--theme-border);
}
@utility border-theme-hover {
border-color: var(--theme-border-hover);
}
@utility shadow-theme {
box-shadow: var(--theme-shadow);
}
@utility shadow-theme-md {
box-shadow: var(--theme-shadow-md);
}
@utility shadow-theme-lg {
box-shadow: var(--theme-shadow-lg);
}
/* ============================================
COMPONENT STYLES
============================================ */
/* Cards */
@utility card {
@apply rounded-lg border;
border-color: var(--theme-border);
background-color: var(--theme-card);
}
@utility card-padded {
@apply rounded-lg border p-6;
border-color: var(--theme-border);
background-color: var(--theme-card);
}
/* Buttons */
@utility btn-primary {
@apply inline-block rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 active:bg-primary-700;
}
@utility btn-danger {
@apply inline-block rounded-lg bg-error-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-error-700 active:bg-error-800;
}
/* Form Input Borders (for checkboxes, radios) */
@utility border-input {
@apply border-surface-300 dark:border-surface-600;
}
/* Interactive hover/active - unifies hover states */
@utility interactive {
@apply transition-colors hover:bg-black/5 active:bg-black/10 dark:hover:bg-white/10 dark:active:bg-white/15;
}
/* Alert utilities */
@utility alert-error {
@apply rounded-lg border border-error-400 bg-error-50 p-3 text-sm text-error-700 dark:border-error-600 dark:bg-error-900/20 dark:text-error-400;
}
@utility alert-success {
@apply rounded-lg border border-success-400 bg-success-50 p-3 text-sm text-success-700 dark:border-success-600 dark:bg-success-900/20 dark:text-success-400;
}
/* Form utilities */
@utility input-base {
@apply w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme placeholder:text-theme-muted focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50;
}
@utility textarea-base {
@apply w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme placeholder:text-theme-muted focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50;
}
@utility form-label {
@apply mb-1.5 block text-sm font-medium text-theme;
}

13
nexus-5-auth-frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NexAuth</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="tap" data-sveltekit-preload-code="viewport">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,284 @@
<script lang="ts">
import type {
LoginFlow,
RegistrationFlow,
RecoveryFlow,
VerificationFlow,
SettingsFlow
} from '@ory/client';
import FormField from './FormField.svelte';
import { filterNodesByGroups, formDataToJson } from '$lib/utils';
import { kratosClient } from '$lib/kratos';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
type Flow = LoginFlow | RegistrationFlow | RecoveryFlow | VerificationFlow | SettingsFlow;
let {
flow,
groups = ['default', 'password', 'profile', 'totp', 'webauthn', 'code', 'link']
}: { flow: Flow; groups?: string[] } = $props();
const nodes = $derived(filterNodesByGroups(flow.ui.nodes, ...groups));
const messages = $derived(flow.ui.messages || []);
const formMethod = $derived((flow.ui.method?.toLowerCase() || 'post') as 'get' | 'post');
// Check if form has password fields for accessibility
const hasPasswordField = $derived(
nodes.some((node) => {
const attrs = node.attributes as any;
return attrs.type === 'password';
})
);
// Get username/email for accessibility (for password managers)
const usernameValue = $derived(() => {
// For settings flow, get email from identity
if ('identity' in flow && flow.identity?.traits) {
return (flow.identity.traits as any).email || '';
}
// For other flows, try to get from nodes
const emailNode = nodes.find((node) => {
const attrs = node.attributes as any;
return attrs.name === 'traits.email' || attrs.name === 'identifier';
});
if (emailNode) {
const attrs = emailNode.attributes as any;
return attrs.value || '';
}
return '';
});
// Helper to determine actual flow type since flow.type might be 'browser'
function getFlowType(flow: Flow): string {
// If flow.type is specific, use it
if (flow.type && flow.type !== 'browser') {
return flow.type;
}
// Try to determine from the form action URL
const actionUrl = flow.ui?.action || '';
if (actionUrl.includes('/self-service/login')) return 'login';
if (actionUrl.includes('/self-service/registration')) return 'registration';
if (actionUrl.includes('/self-service/settings')) return 'settings';
if (actionUrl.includes('/self-service/recovery')) return 'recovery';
if (actionUrl.includes('/self-service/verification')) return 'verification';
// Try to determine from current URL
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname;
if (currentPath.includes('/login')) return 'login';
if (currentPath.includes('/registration')) return 'registration';
if (currentPath.includes('/settings')) return 'settings';
if (currentPath.includes('/recovery')) return 'recovery';
if (currentPath.includes('/verification')) return 'verification';
}
// Fallback to the original type
return flow.type || 'unknown';
}
// SDK-based form submission - handles CSRF automatically via JSON + cookies
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// When using preventDefault, submit button values are not included in FormData
// We need to manually add the method from the clicked submit button
const submitter = event.submitter as HTMLButtonElement;
if (submitter && submitter.name && submitter.value) {
formData.set(submitter.name, submitter.value);
}
const updateBody = formDataToJson(formData);
// Determine flow type from URL or flow properties since flow.type might be 'browser'
const flowType = getFlowType(flow);
// For settings flows, manually add CSRF token if not present in form data
if (flowType === 'settings' && !updateBody.csrf_token) {
// Try to get CSRF token from flow nodes (hidden fields)
const csrfNode = flow.ui.nodes.find((node) => {
const attrs = node.attributes as any;
return attrs?.name === 'csrf_token' && attrs?.type === 'hidden';
});
if (csrfNode) {
const attrs = csrfNode.attributes as any;
if (attrs?.value) {
updateBody.csrf_token = attrs.value;
}
}
}
// Validate flow type before attempting submission
if (!['login', 'registration', 'settings', 'recovery', 'verification'].includes(flowType)) {
console.error(`Unsupported flow type: ${flowType}`);
alert(`Error: Unsupported flow type "${flowType}". Please refresh the page and try again.`);
return;
}
try {
let response;
// Use appropriate SDK method based on flow type
switch (flowType) {
case 'login':
response = await kratosClient.updateLoginFlow({
flow: flow.id,
updateLoginFlowBody: updateBody as any
});
break;
case 'registration':
response = await kratosClient.updateRegistrationFlow({
flow: flow.id,
updateRegistrationFlowBody: updateBody as any
});
break;
case 'settings':
response = await kratosClient.updateSettingsFlow({
flow: flow.id,
updateSettingsFlowBody: updateBody as any
});
break;
case 'recovery':
response = await kratosClient.updateRecoveryFlow({
flow: flow.id,
updateRecoveryFlowBody: updateBody as any
});
break;
case 'verification':
response = await kratosClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: updateBody as any
});
break;
default:
// TypeScript exhaustiveness check - should never reach here
console.error(`Unsupported flow type: ${flowType}`);
return;
}
// Handle success response
const responseData = response.data as any;
// Special handling for recovery and verification flows
// These flows need to stay on the same page to preserve the flow ID
if (flowType === 'recovery' || flowType === 'verification') {
const state = (responseData as any).state;
// If email was sent or we're in a code-entry state, reload with same flow to update UI
if (state === 'sent_email' || state === 'choose_method') {
window.location.href = window.location.pathname + '?flow=' + flow.id;
return;
}
// If we passed the challenge (code verified), follow redirect or go to settings
if (responseData.redirect_browser_to) {
window.location.href = responseData.redirect_browser_to;
return;
}
// Otherwise reload with same flow to show updated state
window.location.href = window.location.pathname + '?flow=' + flow.id;
return;
}
// For other flows, follow redirects or default behavior
if (responseData.redirect_browser_to) {
window.location.href = responseData.redirect_browser_to;
} else {
// For settings, show success message
if (flowType === 'settings') {
window.location.href = window.location.pathname + '?updated=true';
} else {
// For login/registration, check for return_to in flow data first
const returnTo = (flow as any).return_to;
if (returnTo && (flowType === 'login' || flowType === 'registration')) {
// Redirect to the original requested URL
window.location.href = returnTo;
} else {
// Default to home page
window.location.href = '/';
}
}
}
} catch (error: any) {
console.error('Flow submission failed:', error);
// Log detailed error information
if (error.response) {
console.error('Error status:', error.response.status);
console.error('Error data:', JSON.stringify(error.response.data, null, 2));
console.error('Error headers:', error.response.headers);
}
// Handle 422 with redirect_browser_to - this is actually a success case
// Kratos uses 422 to signal that a redirect is needed to complete the flow
if (error.response?.status === 422 && error.response?.data?.redirect_browser_to) {
window.location.href = error.response.data.redirect_browser_to;
return;
}
// Handle flow expired error
if (error.response?.status === 410) {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/${flowType}/browser`;
} else if (error.response?.data?.ui) {
// Update flow with validation errors from server
flow = error.response.data;
} else if (error.response?.data) {
console.error('Server error details:', error.response.data);
// Show error message to user if available
if (error.response.data.error) {
alert(
`Error: ${error.response.data.error.message || JSON.stringify(error.response.data.error)}`
);
}
}
}
}
</script>
<div class="flow-form">
{#if messages.length > 0}
<div class="mb-4 space-y-2">
{#each messages as message}
{#if message.type === 'error'}
<div class="alert-error">
{message.text}
</div>
{:else if message.type === 'success'}
<div class="alert-success">
{message.text}
</div>
{:else}
<div class="rounded-lg border border-primary-400 bg-primary-50 p-4 text-sm text-primary-800 dark:border-primary-600 dark:bg-primary-900/20 dark:text-primary-400">
{message.text}
</div>
{/if}
{/each}
</div>
{/if}
<form action={flow.ui.action} method={formMethod} onsubmit={handleSubmit}>
<!-- Hidden username field for accessibility when form has password fields -->
{#if hasPasswordField && usernameValue()}
<input
type="text"
name="username"
value={usernameValue()}
autocomplete="username"
readonly
style="display: none;"
aria-hidden="true"
tabindex="-1"
/>
{/if}
{#each nodes as node (node.attributes)}
<FormField {node} />
{/each}
</form>
</div>

View File

@ -0,0 +1,243 @@
<script lang="ts">
import type { UiNode, UiNodeInputAttributes } from '@ory/client';
import { getNodeLabel } from '$lib/utils';
import { onMount } from 'svelte';
let { node }: { node: UiNode } = $props();
// Filter out metadata_public fields from registration forms
const shouldHideField = $derived(() => {
const inputAttrs = node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null;
if (!inputAttrs) return false;
// Hide metadata_public fields (these should be set programmatically, not by users)
return inputAttrs.name?.startsWith('metadata_public.');
});
// Check if this is the profile_type field that should be a select
const isProfileTypeSelect = $derived(() => {
const inputAttrs = node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null;
return inputAttrs?.name === 'traits.profile_type';
});
const label = $derived(getNodeLabel(node));
const messages = $derived(node.messages || []);
const hasError = $derived(messages.some((m) => m.type === 'error'));
// Get typed attributes based on node type
const inputAttrs = $derived(
node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null
);
const imageAttrs = $derived(
node.type === 'img' ? (node.attributes as any) : null
);
const textAttrs = $derived(
node.type === 'text' ? (node.attributes as any) : null
);
const scriptAttrs = $derived(
node.type === 'script' ? (node.attributes as any) : null
);
// Handle script nodes by dynamically injecting them into the DOM
onMount(() => {
if (scriptAttrs) {
const script = document.createElement('script');
script.src = scriptAttrs.src;
script.async = scriptAttrs.async;
script.type = scriptAttrs.type;
if (scriptAttrs.integrity) script.integrity = scriptAttrs.integrity;
if (scriptAttrs.crossorigin) script.crossOrigin = scriptAttrs.crossorigin;
if (scriptAttrs.referrerpolicy) script.referrerPolicy = scriptAttrs.referrerpolicy;
document.body.appendChild(script);
return () => {
// Cleanup on unmount
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}
});
// Provide appropriate autocomplete values for better accessibility (only for input nodes)
const autocompleteValue = $derived.by((): any => {
if (!inputAttrs) return undefined;
// If Kratos provides autocomplete, use it (but filter out invalid values)
if (inputAttrs.autocomplete) {
const autocompleteStr = String(inputAttrs.autocomplete);
// Filter out OpenAPI unknown default values
if (autocompleteStr === '11184809') return undefined;
return autocompleteStr;
}
// Fallback autocomplete values based on field name and type
if (inputAttrs.name === 'identifier') return 'username';
if (inputAttrs.name === 'password')
return inputAttrs.type === 'password' ? 'current-password' : 'new-password';
if (inputAttrs.name === 'traits.email') return 'email';
if (inputAttrs.name === 'traits.name.first') return 'given-name';
if (inputAttrs.name === 'traits.name.last') return 'family-name';
if (inputAttrs.name === 'traits.phone') return 'tel';
return undefined;
});
// Handle WebAuthn button clicks
function handleButtonClick(e: MouseEvent) {
if (!inputAttrs) return;
// Handle WebAuthn triggers
if (inputAttrs.onclickTrigger) {
const triggerFn = (window as any)[inputAttrs.onclickTrigger];
if (typeof triggerFn === 'function') {
triggerFn(e.currentTarget);
}
} else if (inputAttrs.onclick) {
// Deprecated onclick - use eval (security risk, but Kratos provides this)
eval(inputAttrs.onclick);
}
}
</script>
{#if shouldHideField()}
<!-- Hidden metadata_public fields - don't render -->
{:else}
<div class="form-field">
{#if node.type === 'script' && scriptAttrs}
<!-- Script node (e.g., WebAuthn scripts) - injected dynamically via onMount into document.body -->
{:else if node.type === 'img' && imageAttrs}
<!-- Image node (e.g., TOTP QR code) -->
<div class="qr-code-container">
{#if label}
<p class="form-label">{label}</p>
{/if}
<img src={imageAttrs.src} alt="QR Code" width={imageAttrs.width} height={imageAttrs.height} />
</div>
{:else if node.type === 'text' && textAttrs}
<!-- Text node (e.g., TOTP secret) -->
<div class="text-node">
<p class="text-sm text-theme-secondary">{textAttrs.text.text}</p>
</div>
{:else if node.type === 'input' && inputAttrs}
<!-- Input nodes -->
{#if inputAttrs.type === 'hidden'}
<input
type="hidden"
name={inputAttrs.name}
value={inputAttrs.value}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
/>
{:else if inputAttrs.type === 'submit' || inputAttrs.type === 'button'}
<button
type={inputAttrs.type}
name={inputAttrs.name}
value={inputAttrs.value}
disabled={inputAttrs.disabled}
data-onclicktrigger={inputAttrs.onclickTrigger}
data-onclick-raw={inputAttrs.onclick}
onclick={handleButtonClick}
class="btn-primary w-full disabled:opacity-50"
>
{label || inputAttrs.value}
</button>
{:else if isProfileTypeSelect()}
<!-- Custom select dropdown for profile_type -->
<label for={inputAttrs.name} class="form-label">
{label}
{#if inputAttrs.required}
<span class="text-error-500">*</span>
{/if}
</label>
<select
name={inputAttrs.name}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
class="input-base disabled:opacity-50"
class:border-error-500={hasError}
>
<option value="">Select profile type...</option>
<option value="team" selected={inputAttrs.value === 'team'}>Team</option>
<option value="customer" selected={inputAttrs.value === 'customer'}>Customer</option>
</select>
{:else if inputAttrs.type === 'checkbox'}
<div class="flex items-center">
<input
type="checkbox"
name={inputAttrs.name}
checked={inputAttrs.value === true}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
/>
<label for={inputAttrs.name} class="ml-2 text-sm text-theme">
{label}
</label>
</div>
{:else}
<label for={inputAttrs.name} class="form-label">
{label}
{#if inputAttrs.required}
<span class="text-error-500">*</span>
{/if}
</label>
<!-- @ts-expect-error: autocomplete string values are valid but TS has strict typing -->
<input
type={inputAttrs.type}
name={inputAttrs.name}
value={inputAttrs.value || ''}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
{...(autocompleteValue ? { autocomplete: autocompleteValue } : {})}
pattern={inputAttrs.pattern}
class="input-base disabled:opacity-50"
class:border-error-500={hasError}
/>
{/if}
{/if}
{#if messages.length > 0}
<div class="mt-1 space-y-1">
{#each messages as message}
<p
class="text-sm"
class:text-error-600={message.type === 'error'}
class:text-theme-secondary={message.type === 'info'}
>
{message.text}
</p>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.form-field {
margin-bottom: 1rem;
}
.qr-code-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background-color: var(--theme-card);
border-radius: 0.5rem;
border: 1px solid var(--theme-border);
}
.qr-code-container img {
max-width: 100%;
height: auto;
}
.text-node {
padding: 0.75rem;
background-color: var(--theme-bg-secondary);
border-radius: 0.375rem;
border: 1px solid var(--theme-border);
}
</style>

View File

@ -0,0 +1,159 @@
<script lang="ts">
import type { SettingsFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
import { formDataToJson } from '$lib/utils';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
let { flow }: { flow: SettingsFlow } = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
// Extract current values from flow
const email = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.email')?.attributes as any)
?.value || ''
);
const firstName = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.name.first')?.attributes as any)
?.value || ''
);
const lastName = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.name.last')?.attributes as any)
?.value || ''
);
const phone = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.phone')?.attributes as any)
?.value || ''
);
const csrfToken = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'csrf_token')?.attributes as any)
?.value || ''
);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
success = false;
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// Add the method field (required by Kratos)
formData.set('method', 'profile');
// Convert to JSON
const updateBody = formDataToJson(formData);
try {
await kratosClient.updateSettingsFlow({
flow: flow.id,
updateSettingsFlowBody: updateBody as any
});
success = true;
// Reload to show updated info
setTimeout(() => {
window.location.href = window.location.pathname + '?updated=true';
}, 1000);
} catch (err: any) {
console.error('Settings update failed:', err);
if (err.response?.status === 410) {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
} else if (err.response?.data?.ui) {
error = 'Validation failed. Please check your input.';
} else {
error = 'Failed to update settings. Please try again.';
}
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- CSRF Token -->
<input type="hidden" name="csrf_token" value={csrfToken} />
{#if error}
<div class="alert-error">
<p>{error}</p>
</div>
{/if}
{#if success}
<div class="alert-success">
<p>Profile updated successfully!</p>
</div>
{/if}
<!-- Email -->
<div>
<label for="email" class="form-label">
Email <span class="text-error-500">*</span>
</label>
<input
type="email"
id="email"
name="traits.email"
value={email}
required
class="input-base"
/>
</div>
<!-- First Name -->
<div>
<label for="first-name" class="form-label">
First Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="first-name"
name="traits.name.first"
value={firstName}
required
class="input-base"
/>
</div>
<!-- Last Name -->
<div>
<label for="last-name" class="form-label">
Last Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="last-name"
name="traits.name.last"
value={lastName}
required
class="input-base"
/>
</div>
<!-- Phone (Optional) -->
<div>
<label for="phone" class="form-label">Phone Number</label>
<input
type="tel"
id="phone"
name="traits.phone"
value={phone}
pattern="^[0-9\s\-+()]*$"
class="input-base"
/>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading}
class="btn-primary w-full disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</form>

View File

@ -0,0 +1,118 @@
<script lang="ts">
import { Modal } from 'flowbite-svelte';
interface CreateForm {
schema_id: string;
traits: {
email: string;
name: {
first: string;
last: string;
};
profile_type: string;
};
}
interface Props {
open: boolean;
form: CreateForm;
onClose: () => void;
onCreate: () => Promise<void>;
loading?: boolean;
error?: string | null;
}
let { open, form, onClose, onCreate, loading = false, error = null }: Props = $props();
</script>
<Modal
bind:open
onclose={onClose}
size="lg"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Create New Identity</h3>
{/snippet}
<div class="w-full">
{#if error}
<div class="mb-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<div class="space-y-4">
<div>
<label for="create-email" class="block text-sm font-medium text-gray-700">Email *</label>
<input
id="create-email"
type="email"
bind:value={form.traits.email}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="create-first-name" class="block text-sm font-medium text-gray-700"
>First Name</label
>
<input
id="create-first-name"
type="text"
bind:value={form.traits.name.first}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="create-last-name" class="block text-sm font-medium text-gray-700"
>Last Name</label
>
<input
id="create-last-name"
type="text"
bind:value={form.traits.name.last}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label for="create-profile-type" class="block text-sm font-medium text-gray-700"
>Profile Type *</label
>
<select
id="create-profile-type"
bind:value={form.traits.profile_type}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="team">Team</option>
<option value="customer">Customer</option>
</select>
</div>
</div>
</div>
{#snippet footer()}
<button
type="button"
onclick={onCreate}
disabled={loading || !form.traits.email}
class="rounded-md bg-green-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create Identity'}
</button>
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Cancel
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import type { Identity } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
identity: Identity | null;
onClose: () => void;
onDeleteCredential: (identityId: string, type: string, identifier?: string) => Promise<void>;
}
let { identity, onClose, onDeleteCredential }: Props = $props();
let open = $derived(!!identity);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Identity Details</h3>
{/snippet}
<div class="w-full">
<!-- Basic Info -->
<div class="mb-6 grid grid-cols-2 gap-4">
<div>
<span class="block text-sm font-medium text-gray-700">Email</span>
<p class="mt-1 text-sm text-gray-900">{identity?.traits?.email || 'N/A'}</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">Name</span>
<p class="mt-1 text-sm text-gray-900">
{#if identity?.traits?.name}
{identity.traits.name.first || ''} {identity.traits.name.last || ''}
{:else}
N/A
{/if}
</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">State</span>
<p class="mt-1 text-sm text-gray-900">{identity?.state || 'N/A'}</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">Created</span>
<p class="mt-1 text-sm text-gray-900">
{identity?.created_at ? new Date(identity.created_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
<!-- Credentials Section -->
{#if identity?.credentials}
<div class="mb-6">
<h4 class="text-md mb-3 font-medium text-gray-900">Authentication Methods</h4>
<div class="space-y-3">
{#each Object.entries(identity.credentials) as [type, credential]}
<div class="rounded-lg border border-gray-200 p-3">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-gray-900 capitalize">{type}</span>
{#if credential.identifiers && credential.identifiers.length > 0}
<div class="mt-1">
{#each credential.identifiers as identifier}
<span class="block text-xs text-gray-600">{identifier}</span>
{/each}
</div>
{/if}
{#if credential.created_at}
<p class="mt-1 text-xs text-gray-500">
Added {new Date(credential.created_at).toLocaleDateString()}
</p>
{/if}
</div>
{#if type !== 'password' && type !== 'code'}
<button
onclick={() =>
onDeleteCredential(identity.id, type, credential.identifiers?.[0])}
class="rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50 hover:text-red-900"
>
Remove
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Raw JSON (collapsed by default) -->
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
View Raw JSON
</summary>
<div class="mt-2 rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-xs text-gray-800">{JSON.stringify(identity, null, 2)}</pre>
</div>
</details>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,183 @@
<script lang="ts">
import type { Identity } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
identity: Identity | null;
onClose: () => void;
onSave: () => Promise<void>;
loading?: boolean;
error?: string | null;
}
let { identity, onClose, onSave, loading = false, error = null }: Props = $props();
let open = $derived(!!identity);
// Helper getters/setters for metadata_public fields
let djangoProfileId = $derived.by(() => {
if (!identity?.metadata_public) return '';
return (identity.metadata_public as any)?.django_profile_id || '';
});
function updateMetadata(field: string, value: string) {
if (!identity) return;
if (!identity.metadata_public) {
identity.metadata_public = {};
}
(identity.metadata_public as any)[field] = value || undefined;
}
function handleDjangoProfileIdInput(e: Event) {
const target = e.target as HTMLInputElement;
updateMetadata('django_profile_id', target.value);
}
</script>
<Modal
bind:open
onclose={onClose}
size="lg"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Edit Identity</h3>
{/snippet}
<div class="w-full">
{#if error}
<div class="mb-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
{#if identity}
<div class="space-y-4">
<!-- Email -->
<div>
<label for="edit-email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="edit-email"
type="email"
bind:value={identity.traits.email}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="edit-first-name" class="block text-sm font-medium text-gray-700"
>First Name</label
>
<input
id="edit-first-name"
type="text"
bind:value={identity.traits.name.first}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="edit-last-name" class="block text-sm font-medium text-gray-700"
>Last Name</label
>
<input
id="edit-last-name"
type="text"
bind:value={identity.traits.name.last}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Phone -->
<div>
<label for="edit-phone" class="block text-sm font-medium text-gray-700"
>Phone Number</label
>
<input
id="edit-phone"
type="tel"
bind:value={identity.traits.phone}
pattern="^[0-9\s\-+()]*$"
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-500">Optional</p>
</div>
<!-- Profile Type -->
<div>
<label for="edit-profile-type" class="block text-sm font-medium text-gray-700"
>Profile Type</label
>
<select
id="edit-profile-type"
bind:value={identity.traits.profile_type}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="team">Team Member</option>
<option value="customer">Customer</option>
</select>
<p class="mt-1 text-xs text-gray-500">Determines account type and permissions</p>
</div>
<!-- State -->
<div>
<label for="edit-state" class="block text-sm font-medium text-gray-700">State</label>
<select
id="edit-state"
bind:value={identity.state}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<!-- Metadata Public Section -->
<div class="border-t border-gray-200 pt-4">
<h4 class="mb-3 text-sm font-semibold text-gray-900">System Metadata (Public)</h4>
<p class="mb-3 text-xs text-gray-500">
Read-only for users, editable by admin. Links to Django backend.
</p>
<div class="space-y-3">
<div>
<label for="edit-django-profile-id" class="block text-sm font-medium text-gray-700"
>Django Profile ID</label
>
<input
id="edit-django-profile-id"
type="text"
value={djangoProfileId}
oninput={handleDjangoProfileIdInput}
placeholder="UUID of linked Django profile"
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 font-mono text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
{/if}
</div>
{#snippet footer()}
<button
type="button"
onclick={onSave}
disabled={loading}
class="rounded-md bg-blue-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Cancel
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import type { Identity, Session } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface ModalData {
identity: Identity;
sessions: Session[];
}
interface Props {
data: ModalData | null;
onClose: () => void;
onViewSession: (session: Session) => void;
onExtendSession: (id: string) => Promise<void>;
onDeleteSession: (id: string) => Promise<void>;
}
let { data, onClose, onViewSession, onExtendSession, onDeleteSession }: Props = $props();
let open = $derived(!!data);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Sessions for {data?.identity.traits?.email || 'User'}
</h3>
<p class="mt-1 text-sm text-gray-600">
{data?.sessions.length || 0} active
{data?.sessions.length === 1 ? 'session' : 'sessions'}
</p>
</div>
{/snippet}
<div class="w-full">
{#if data && data.sessions.length === 0}
<p class="py-8 text-center text-gray-500">No active sessions for this user.</p>
{:else if data}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Session ID
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Issued At
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Expires At
</th>
<th
class="px-4 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.sessions as session (session.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-4 font-mono text-sm whitespace-nowrap text-gray-600">
{session.id.substring(0, 12)}...
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.issued_at || '').toLocaleString()}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.expires_at || '').toLocaleString()}
</td>
<td class="space-x-2 px-4 py-4 text-right text-sm whitespace-nowrap">
<button
onclick={() => onViewSession(session)}
class="text-blue-600 hover:text-blue-900"
>
View
</button>
<button
onclick={() => onExtendSession(session.id)}
class="text-green-600 hover:text-green-900"
>
Extend
</button>
<button
onclick={() => onDeleteSession(session.id)}
class="text-red-600 hover:text-red-900"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import type { Message } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
message: Message | null;
onClose: () => void;
}
let { message, onClose }: Props = $props();
let open = $derived(!!message);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Message Details</h3>
{/snippet}
<div class="rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-sm text-gray-800">{JSON.stringify(message, null, 2)}</pre>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import type { Session } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
session: Session | null;
onClose: () => void;
}
let { session, onClose }: Props = $props();
let open = $derived(!!session);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Session Details</h3>
{/snippet}
<div class="rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-sm text-gray-800">{JSON.stringify(session, null, 2)}</pre>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,96 @@
import { kratosServerClient } from './kratos-server';
import type {
LoginFlow,
RegistrationFlow,
RecoveryFlow,
VerificationFlow,
SettingsFlow
} from '@ory/client';
// Utility to (a) get flow if id present, (b) create the new browser flow if not, and
// (c) transparently re-init on stale/expired/forbidden flows similar to Ory Kratos UI
async function getOrCreateFlow<
T extends LoginFlow | RegistrationFlow | RecoveryFlow | VerificationFlow | SettingsFlow
>(params: {
flowId: string | null;
create: () => Promise<T>;
get: () => Promise<T>;
redirectBasePath: string; // e.g. '/login'
searchParams?: URLSearchParams;
excludeParams?: string[]; // Additional params to exclude from redirect (e.g., 'code' for verification/recovery)
}): Promise<{ flow: T; redirectTo?: string }> {
const { flowId, create, get, redirectBasePath, searchParams, excludeParams = [] } = params;
const buildRedirect = (flow: T) => {
const sp = new URLSearchParams();
// Parameters to exclude from redirect URL
const excluded = new Set(['flow', ...excludeParams]);
// Only copy non-excluded search params
if (searchParams) {
for (const [key, value] of searchParams.entries()) {
if (!excluded.has(key)) {
sp.set(key, value);
}
}
}
sp.set('flow', flow.id);
return `${redirectBasePath}?${sp.toString()}`;
};
try {
if (flowId) {
const flow = await get();
return { flow };
}
const flow = await create();
return { flow, redirectTo: buildRedirect(flow) };
} catch (e: any) {
// Handle common Kratos flow errors by re-initializing the flow
// 410 (Gone) - flow expired; 403 (Forbidden) - CSRF or not allowed; 400 (Bad Request) - invalid id
if ([410, 403, 400].includes(e?.status || e?.response?.status)) {
try {
const flow = await create();
return { flow, redirectTo: buildRedirect(flow) };
} catch (ee: any) {
throw ee;
}
}
throw e;
}
}
export async function loadRecoveryFlow(flowId: string | null, searchParams?: URLSearchParams) {
return getOrCreateFlow<RecoveryFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserRecoveryFlow()).data,
get: async () => (await kratosServerClient.getRecoveryFlow({ id: flowId! })).data,
redirectBasePath: '/recovery',
searchParams,
excludeParams: ['code'] // Don't preserve recovery codes across flow recreations
});
}
export async function loadVerificationFlow(flowId: string | null, searchParams?: URLSearchParams) {
return getOrCreateFlow<VerificationFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserVerificationFlow()).data,
get: async () => (await kratosServerClient.getVerificationFlow({ id: flowId! })).data,
redirectBasePath: '/verification',
searchParams,
excludeParams: ['code'] // Don't preserve verification codes across flow recreations
});
}
export async function loadSettingsFlow(
flowId: string | null,
cookie: string,
searchParams?: URLSearchParams
) {
return getOrCreateFlow<SettingsFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserSettingsFlow({ cookie })).data,
get: async () => (await kratosServerClient.getSettingsFlow({ id: flowId!, cookie })).data,
redirectBasePath: '/settings',
searchParams
});
}

View File

@ -0,0 +1,11 @@
import { Configuration, FrontendApi } from '@ory/client';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import { KRATOS_SERVER_URL } from '$env/static/private';
// Server-side client (without browser-specific settings)
// Used only for session validation in server-side loaders
export const kratosServerClient = new FrontendApi(
new Configuration({
basePath: KRATOS_SERVER_URL || PUBLIC_KRATOS_URL || 'http://localhost:4455'
})
);

View File

@ -0,0 +1,24 @@
import { Configuration, FrontendApi, IdentityApi } from '@ory/client';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
// Browser-side client (with credentials)
// All browser calls (including admin API) go through Oathkeeper at PUBLIC_KRATOS_URL
export const kratosClient = new FrontendApi(
new Configuration({
basePath: PUBLIC_KRATOS_URL || 'http://localhost:4455',
baseOptions: {
withCredentials: true
}
})
);
// Admin client for administrative operations (with credentials)
// Uses the same proxy but with IdentityApi for admin operations
export const kratosAdminClient = new IdentityApi(
new Configuration({
basePath: PUBLIC_KRATOS_URL || 'http://localhost:4455',
baseOptions: {
withCredentials: true
}
})
);

View File

@ -0,0 +1,79 @@
import { browser } from '$app/environment';
type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'theme-preference';
function getSystemTheme(): 'light' | 'dark' {
if (!browser) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getStoredTheme(): Theme {
if (!browser) return 'system';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
}
function createThemeStore() {
let preference = $state<Theme>(getStoredTheme());
let resolved = $derived<'light' | 'dark'>(
preference === 'system' ? getSystemTheme() : preference
);
function applyTheme(theme: 'light' | 'dark') {
if (!browser) return;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
}
function setTheme(theme: Theme) {
preference = theme;
if (browser) {
localStorage.setItem(STORAGE_KEY, theme);
applyTheme(resolved);
}
}
function toggle() {
const newTheme = resolved === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
function init() {
if (!browser) return;
applyTheme(resolved);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (preference === 'system') {
resolved = e.matches ? 'dark' : 'light';
applyTheme(resolved);
}
};
mediaQuery.addEventListener('change', handleChange);
}
return {
get preference() {
return preference;
},
get resolved() {
return resolved;
},
get isDark() {
return resolved === 'dark';
},
setTheme,
toggle,
init
};
}
export const theme = createThemeStore();

View File

@ -0,0 +1,52 @@
import type { UiNode, UiNodeInputAttributes } from '@ory/client';
export function getNodeLabel(node: UiNode): string {
const attrs = node.attributes as UiNodeInputAttributes;
if (node.meta.label?.text) {
return node.meta.label.text;
}
return attrs.name || '';
}
export function filterNodesByGroups(nodes: UiNode[], ...groups: string[]): UiNode[] {
return nodes.filter((node) => groups.includes(node.group));
}
/**
* Helper function to set nested property in an object using dot notation
* Example: setNestedProperty(obj, 'traits.name.first', 'John')
*/
export function setNestedProperty(obj: any, path: string, value: any): void {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current)) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
/**
* Convert FormData to JSON object for Ory SDK submission
* This handles nested properties (like traits.email, traits.name.first)
* and includes the csrf_token which must be present in JSON requests
*/
export function formDataToJson(formData: FormData): Record<string, any> {
const json: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
// Handle nested object notation (e.g., traits.email, traits.name.first)
if (key.includes('.')) {
setNestedProperty(json, key, value);
} else {
json[key] = value;
}
}
return json;
}

View File

@ -0,0 +1,23 @@
import { kratosServerClient } from '$lib/kratos-server';
import { ADMIN_USER_ID } from '$env/static/private';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
try {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
return { session: null, isAdmin: false };
}
const { data: session } = await kratosServerClient.toSession({
cookie: `ory_kratos_session=${sessionToken}`
});
const isAdmin = session?.identity?.id === ADMIN_USER_ID;
return { session, isAdmin };
} catch {
return { session: null, isAdmin: false };
}
};

View File

@ -0,0 +1,187 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { theme } from '$lib/stores/theme.svelte';
import { onMount } from 'svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: any } = $props();
let menuOpen = $state(false);
const isActive = $derived((path: string) => page.url.pathname === path);
function toggleMenu() {
menuOpen = !menuOpen;
}
function closeMenu() {
menuOpen = false;
}
onMount(() => {
theme.init();
});
</script>
<div class="flex min-h-screen flex-col bg-theme">
<nav class="border-b border-theme bg-theme-secondary shadow-theme">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Left section: Hamburger + Logo -->
<div class="flex items-center gap-3">
<!-- Hamburger menu button -->
<button
onclick={toggleMenu}
class="inline-flex items-center justify-center rounded-lg p-2.5 text-theme-secondary transition-colors interactive"
aria-expanded={menuOpen}
aria-label="Toggle navigation menu"
>
{#if !menuOpen}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
<!-- Logo -->
<a href="/" class="flex items-center space-x-2">
<span class="text-xl font-bold text-theme">Nexus</span>
<span class="text-theme-muted">|</span>
<span class="text-base font-medium text-theme-secondary">Account</span>
</a>
</div>
<!-- Right section: Auth buttons and theme toggle -->
<div class="flex items-center gap-2">
<!-- Theme toggle button -->
<button
onclick={() => theme.toggle()}
class="rounded-lg p-2.5 text-theme-secondary transition-colors interactive"
aria-label="Toggle theme"
>
{#if theme.isDark}
<!-- Sun icon for light mode -->
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<!-- Moon icon for dark mode -->
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
{#if data.session}
<span class="mr-2 hidden text-sm text-theme-secondary sm:inline">
{data.session.identity?.traits.email}
</span>
<form action="/logout" method="POST">
<input type="hidden" name="return_to" value={page.url.origin} />
<button type="submit" class="btn-danger">
Logout
</button>
</form>
{:else}
<div class="flex gap-2">
<a
href="/login"
class="rounded-lg border border-theme px-4 py-2 text-sm font-medium text-theme-secondary transition-colors interactive"
>
Login
</a>
<a href="/registration" class="btn-primary text-sm">
Register
</a>
</div>
{/if}
</div>
</div>
</div>
</nav>
<!-- Dropdown menu overlay -->
{#if menuOpen}
<!-- Backdrop to close menu when clicking outside -->
<button
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onclick={closeMenu}
aria-label="Close menu"
></button>
<!-- Menu panel -->
<div class="fixed top-16 right-0 left-0 z-50 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="rounded-b-lg border border-t-0 border-theme bg-theme-card shadow-theme-lg">
<!-- Navigation items -->
<div class="space-y-1 px-2 pt-2 pb-3">
<a
href="/"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Home
</a>
{#if data.session}
<a
href="/settings"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/settings'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Settings
</a>
{#if data.isAdmin}
<a
href="/admin"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/admin'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Admin
</a>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<main class="mx-auto flex-1 max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{@render children()}
</main>
</div>

View File

@ -0,0 +1,135 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const session = $derived(data.session);
const identity = $derived(session?.identity);
const traits = $derived(identity?.traits);
</script>
<svelte:head>
<title>Home - Nexus Nexus</title>
</svelte:head>
{#if session}
<div class="mx-auto max-w-4xl">
<div class="card-padded overflow-hidden shadow-theme">
<h1 class="mb-6 text-2xl font-bold text-theme sm:text-3xl">Welcome back!</h1>
<div class="space-y-6">
<div>
<h2 class="text-lg font-medium text-theme">Account Information</h2>
<dl class="mt-4 grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-theme-muted">Email</dt>
<dd class="mt-1 text-sm text-theme">{traits?.email || 'Not set'}</dd>
</div>
{#if traits?.name?.first || traits?.name?.last}
<div>
<dt class="text-sm font-medium text-theme-muted">Name</dt>
<dd class="mt-1 text-sm text-theme">
{traits.name.first}
{traits.name.last}
</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-theme-muted">User ID</dt>
<dd class="mt-1 font-mono text-sm text-theme">{identity?.id}</dd>
</div>
<div>
<dt class="text-sm font-medium text-theme-muted">Session Active</dt>
<dd class="mt-1 text-sm text-theme">
{session.active ? 'Yes' : 'No'}
</dd>
</div>
{#if session.expires_at}
<div>
<dt class="text-sm font-medium text-theme-muted">Session Expires</dt>
<dd class="mt-1 text-sm text-theme">
{new Date(session.expires_at).toLocaleString()}
</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-theme-muted">Authentication Level</dt>
<dd class="mt-1 text-sm text-theme">
{session.authenticator_assurance_level === 'aal2'
? 'Two-Factor'
: 'Single-Factor'}
</dd>
</div>
</dl>
</div>
{#if session.authentication_methods && session.authentication_methods.length > 0}
<div>
<h3 class="text-lg font-medium text-theme">Authentication Methods</h3>
<ul class="mt-2 space-y-2">
{#each session.authentication_methods as method}
<li class="flex items-center text-sm text-theme-secondary">
<svg class="mr-2 h-5 w-5 text-success-500" viewBox="0 0 20 20" aria-hidden="true">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
/>
</svg>
{method.method} (completed at {new Date(
method.completed_at || ''
).toLocaleString()})
</li>
{/each}
</ul>
</div>
{/if}
<div class="pt-4">
<a href="/settings" class="btn-primary">
Manage Account Settings
</a>
</div>
</div>
</div>
</div>
{:else}
<div class="text-center">
<h1 class="text-3xl font-bold tracking-tight text-theme sm:text-5xl">
Welcome to Nexus Nexus
</h1>
<p class="mt-6 text-lg leading-8 text-theme-secondary">
A secure authentication platform built with Ory Kratos and SvelteKit
</p>
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row sm:gap-6">
<a href="/registration" class="btn-primary w-full px-6 py-3 text-base sm:w-auto">
Get started
</a>
<a href="/login" class="text-base font-semibold leading-7 text-theme">
Sign in <span aria-hidden="true"></span>
</a>
</div>
<div class="mt-16 grid grid-cols-1 gap-6 sm:grid-cols-3">
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Secure Authentication</h3>
<p class="mt-2 text-sm text-theme-secondary">
Powered by Ory Kratos with support for passwords, 2FA, and passwordless login
</p>
</div>
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Account Recovery</h3>
<p class="mt-2 text-sm text-theme-secondary">
Easy password recovery and email verification flows to keep your account secure
</p>
</div>
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Self-Service Settings</h3>
<p class="mt-2 text-sm text-theme-secondary">
Manage your profile, change passwords, and configure two-factor authentication
</p>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,31 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { ADMIN_USER_ID } from '$env/static/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
redirect(303, '/login?return_to=/admin');
}
try {
const { data: session } = await kratosServerClient.toSession({
cookie: `ory_kratos_session=${sessionToken}`
});
// Check if the user is the admin
if (session.identity?.id !== ADMIN_USER_ID) {
redirect(303, '/?error=unauthorized');
}
return {
session,
isAdmin: true
};
} catch {
// If session validation fails, redirect to login
redirect(303, '/login?return_to=/admin');
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
import { kratosServerClient } from '$lib/kratos-server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const flowId = url.searchParams.get('id');
if (!flowId) {
return {
errorMessage: 'An error occurred. Please try again.'
};
}
try {
const { data: flow } = await kratosServerClient.getFlowError({ id: flowId });
return { flow };
} catch (error) {
console.error('Error flow error:', error);
return {
errorMessage: 'An error occurred. Please try again.'
};
}
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const errorDetails = $derived(
(data.flow?.error as { message?: string; reason?: string } | null) || null
);
const errorMessage = $derived(data.errorMessage || errorDetails?.message || 'An error occurred');
</script>
<svelte:head>
<title>Error - Nexus Nexus</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-md">
<div class="alert-error">
<div class="flex gap-3">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-error-400" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-medium">Error</h3>
<div class="mt-2 text-sm">
<p>{errorMessage}</p>
{#if errorDetails?.reason}
<p class="mt-2">{errorDetails.reason}</p>
{/if}
</div>
<div class="mt-4">
<a href="/" class="btn-danger">
Go back home
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import FlowForm from '$lib/components/FlowForm.svelte';
import type { LoginFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<LoginFlow | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const flowId = page.url.searchParams.get('flow');
if (flowId) {
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getLoginFlow({ id: flowId });
flow = data;
} catch (err) {
console.error('Login flow error:', err);
error = 'Flow expired or invalid. Please try again.';
} finally {
loading = false;
}
} else {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/login/browser`;
}
});
</script>
<svelte:head>
<title>Login - Nexus Nexus</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
Sign in to your account
</h2>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
{#if loading}
<p class="text-center text-theme-muted">Loading...</p>
{:else if error}
<div class="alert-error mb-4">
<p>{error}</p>
</div>
<p class="text-center">
<a href="/login" class="text-primary-500 hover:text-primary-600">Try again</a>
</p>
{:else if flow}
<FlowForm {flow} groups={['default', 'password']} />
{:else}
<div class="alert-error">
<p>Failed to load login form</p>
</div>
{/if}
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Don't have an account?
<a href="/registration" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Register here
</a>
</p>
<p class="mt-2 text-center text-sm text-theme-secondary">
<a href="/recovery" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Forgot your password?
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,56 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
export const POST: RequestHandler = async ({ cookies, fetch, request }) => {
const formData = await request.formData();
const returnTo = formData.get('return_to')?.toString() || 'https://account.example.com';
try {
const sessionToken = cookies.get('ory_kratos_session');
if (sessionToken) {
// Create a logout flow through Oathkeeper with return_to parameter
const logoutUrl = new URL(`${PUBLIC_KRATOS_URL}/self-service/logout/browser`);
logoutUrl.searchParams.set('return_to', returnTo);
const response = await fetch(logoutUrl.toString(), {
method: 'GET',
headers: {
cookie: `ory_kratos_session=${sessionToken}`
},
redirect: 'manual'
});
// Get the logout token from response
if (response.status === 200) {
const data = await response.json();
if (data.logout_url) {
// Execute logout - this will redirect to return_to after logout
await fetch(data.logout_url, {
method: 'GET',
headers: {
cookie: `ory_kratos_session=${sessionToken}`
}
});
}
}
}
// Clear cookie on the server side with matching attributes
cookies.delete('ory_kratos_session', {
path: '/',
domain: '.example.com'
});
} catch (error) {
console.error('Logout error:', error);
// Continue to redirect even if logout fails
}
// Redirect to the return_to URL or default to login page
if (returnTo && returnTo !== 'https://account.example.com') {
throw redirect(303, returnTo);
}
throw redirect(303, '/login');
};

View File

@ -0,0 +1,76 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, request }) => {
const flowId = url.searchParams.get('flow');
const code = url.searchParams.get('code');
// If no flow ID, redirect to Kratos to create the flow (with proper CSRF cookie handling)
if (!flowId) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/recovery/browser`);
}
// Load the existing flow
const cookie = request.headers.get('cookie') || undefined;
let flow;
try {
const result = await kratosServerClient.getRecoveryFlow({ id: flowId, cookie });
flow = result.data;
} catch (error: any) {
// If flow is expired/invalid, redirect to create a new one
if ([410, 403, 400].includes(error?.status || error?.response?.status)) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/recovery/browser`);
}
throw error;
}
// If we have a valid flow and code, auto-submit the recovery code
if (flow && code) {
try {
const result = await kratosServerClient.updateRecoveryFlow({
flow: flow.id,
updateRecoveryFlowBody: {
method: 'code',
code: code
}
});
// Recovery code submitted successfully
// Check if Kratos wants us to redirect somewhere (usually to settings to set new password)
if (result.data && (result.data as any).redirect_browser_to) {
throw redirect(303, (result.data as any).redirect_browser_to);
}
// Otherwise update the flow with the result
flow = result.data;
} catch (error: any) {
// Re-throw if this is a redirect (SvelteKit internal)
if (error?.status === 303 || error?.location) {
throw error;
}
console.error('Auto recovery failed:', error);
// If Kratos returned an updated flow with error messages, use it
if (error.response?.data?.ui) {
flow = error.response.data;
} else {
// Add a user-friendly error message to the flow UI
if (!flow.ui.messages) {
flow.ui.messages = [];
}
flow.ui.messages.push({
id: 4060001,
text: 'The recovery code has expired or is invalid. Please request a new recovery email by entering your email address below.',
type: 'error',
context: {}
});
}
}
}
return { flow };
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Derive UI text based on recovery flow state
const flowState = $derived(data.flow.state as string);
const heading = $derived(
flowState === 'sent_email' || flowState === 'passed_challenge'
? 'Enter recovery code'
: 'Recover your password'
);
const description = $derived(
flowState === 'sent_email'
? 'Enter the 6-digit recovery code sent to your email'
: flowState === 'passed_challenge'
? 'Recovery successful! Update your password below'
: 'Enter your email address to receive a recovery code'
);
</script>
<svelte:head>
<title>Password Recovery - Nexus Nexus</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
{heading}
</h2>
<p class="mt-2 text-center text-sm text-theme-secondary">
{description}
</p>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
<FlowForm flow={data.flow} groups={['default', 'code', 'link']} />
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Remember your password?
<a href="/login" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Sign in here
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import FlowForm from '$lib/components/FlowForm.svelte';
import type { RegistrationFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<RegistrationFlow | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const flowId = page.url.searchParams.get('flow');
if (flowId) {
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getRegistrationFlow({ id: flowId });
flow = data;
} catch (err) {
console.error('Registration flow error:', err);
error = 'Failed to load registration form. Please try again.';
} finally {
loading = false;
}
} else {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/registration/browser`;
}
});
</script>
<svelte:head>
<title>Register - Nexus Nexus</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
Create your account
</h2>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
{#if loading}
<p class="text-center text-theme-muted">Loading...</p>
{:else if error}
<div class="alert-error mb-4">
<p>{error}</p>
</div>
<p class="text-center">
<a href="/registration" class="text-primary-500 hover:text-primary-600">Try again</a>
</p>
{:else if flow}
<FlowForm {flow} groups={['default', 'password', 'profile']} />
{:else}
<div class="alert-error">
<p>Failed to load registration form</p>
</div>
{/if}
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Already have an account?
<a href="/login" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Sign in here
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,21 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Only validate session on the server. Do NOT create/fetch the settings flow here
// so that Kratos' Set-Cookie (csrf) reaches the browser directly when the flow
// is initialized client-side.
export const load: PageServerLoad = async ({ cookies }) => {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
throw redirect(303, '/login');
}
const sessionCookie = `ory_kratos_session=${sessionToken}`;
try {
await kratosServerClient.toSession({ cookie: sessionCookie });
} catch {
throw redirect(303, '/login');
}
return {};
};

View File

@ -0,0 +1,82 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import SettingsProfileForm from '$lib/components/SettingsProfileForm.svelte';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { SettingsFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<SettingsFlow | null>(null);
let isLoading = $state(true);
const isUpdated = $derived(page.url.searchParams.has('updated'));
onMount(async () => {
const url = new URL(window.location.href);
const flowId = url.searchParams.get('flow');
if (!flowId) {
// Redirect to create flow
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
return;
}
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getSettingsFlow({ id: flowId });
flow = data;
isLoading = false;
} catch (error) {
console.error('Failed to fetch settings flow:', error);
// Restart flow on error
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
}
});
</script>
<svelte:head>
<title>Settings - Nexus Nexus</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h1 class="mb-8 text-2xl font-bold text-theme sm:text-3xl">Account Settings</h1>
{#if isUpdated}
<div class="alert-success mb-6">
<p>Your settings have been updated successfully!</p>
</div>
{/if}
{#if isLoading}
<div class="card-padded shadow-theme">
<p class="text-center text-theme-muted">Loading settings…</p>
</div>
{:else if flow}
<div class="card-padded shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Profile Settings</h2>
<p class="mb-4 text-sm text-theme-secondary">Update your personal information</p>
<SettingsProfileForm {flow} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Password</h2>
<FlowForm {flow} groups={['password']} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Authenticator App (TOTP)</h2>
<p class="mb-4 text-sm text-theme-secondary">
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
</p>
<FlowForm {flow} groups={['totp']} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Security Keys & Biometrics (WebAuthn)</h2>
<p class="mb-4 text-sm text-theme-secondary">
Use hardware security keys (like YubiKey) or biometric authentication (like Face ID or Touch ID) for enhanced security.
</p>
<FlowForm {flow} groups={['webauthn']} />
</div>
{/if}
</div>

View File

@ -0,0 +1,77 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, request }) => {
const flowId = url.searchParams.get('flow');
const code = url.searchParams.get('code');
// If no flow ID, redirect to Kratos to create the flow (with proper CSRF cookie handling)
if (!flowId) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/verification/browser`);
}
// Load the existing flow
const cookie = request.headers.get('cookie') || undefined;
let flow;
try {
const result = await kratosServerClient.getVerificationFlow({ id: flowId, cookie });
flow = result.data;
} catch (error: any) {
// If flow is expired/invalid, redirect to create a new one
if ([410, 403, 400].includes(error?.status || error?.response?.status)) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/verification/browser`);
}
throw error;
}
// If we have a valid flow and code, auto-submit the verification
if (flow && code) {
try {
const result = await kratosServerClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: {
method: 'code',
code: code
},
cookie // Pass session cookie so Kratos can associate verification with authenticated user
});
// Verification code submitted successfully
// Check if Kratos wants us to redirect somewhere
if (result.data && (result.data as any).redirect_browser_to) {
throw redirect(303, (result.data as any).redirect_browser_to);
}
// Otherwise redirect to home
throw redirect(303, '/');
} catch (error: any) {
// Re-throw if this is a redirect (SvelteKit internal)
if (error?.status === 303 || error?.location) {
throw error;
}
console.error('Auto verification failed:', error);
// If Kratos returned an updated flow with error messages, use it
if (error.response?.data?.ui) {
flow = error.response.data;
} else {
// Add a user-friendly error message to the flow UI
if (!flow.ui.messages) {
flow.ui.messages = [];
}
flow.ui.messages.push({
id: 4070001,
text: 'The verification code has expired or is invalid. Please request a new verification email by entering your email address below.',
type: 'error',
context: {}
});
}
}
}
return { flow };
};

View File

@ -0,0 +1,42 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Derive UI text based on verification flow state
const flowState = $derived(data.flow.state as string);
const heading = $derived(
flowState === 'sent_email' || flowState === 'passed_challenge'
? 'Enter verification code'
: 'Verify your email address'
);
const description = $derived(
flowState === 'sent_email'
? 'Enter the 6-digit verification code sent to your email'
: flowState === 'passed_challenge'
? 'Email verified successfully!'
: 'Enter your email address to receive a verification code'
);
</script>
<svelte:head>
<title>Email Verification - Nexus Nexus</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
{heading}
</h2>
<p class="mt-2 text-center text-sm text-theme-secondary">
{description}
</p>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
<FlowForm flow={data.flow} groups={['default', 'code', 'link']} />
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
export default config;

View File

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});

View File

5
nexus-5-auth-kratos/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
.env.dev
.env.prod
/.env.production
/.env.development

View File

@ -0,0 +1,32 @@
ARG KRATOS_VERSION=v1.1.0
FROM oryd/kratos:${KRATOS_VERSION}
# Switch to root to install packages
USER root
# Install additional tools if needed
RUN apk add --no-cache curl wget
# Set working directory
WORKDIR /etc/kratos
# Copy configuration files
COPY config/kratos.yml /etc/kratos/kratos.yml
COPY config/identity.schema.json /etc/kratos/identity.schema.json
COPY config/identity.v2.schema.json /etc/kratos/identity.v2.schema.json
# Validate configuration syntax at build time (optional)
RUN kratos help serve || true
# Switch back to non-root user for runtime
USER ory
# Expose ports
EXPOSE 4433 4434
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f -s http://localhost:4433/health/ready > /dev/null || exit 1
# Default command
CMD ["serve", "--config", "/etc/kratos/kratos.yml"]

View File

@ -0,0 +1,58 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"totp": {
"account_name": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"name": {
"type": "object",
"properties": {
"first": {
"type": "string",
"title": "First Name",
"minLength": 1,
"maxLength": 100
},
"last": {
"type": "string",
"title": "Last Name",
"minLength": 1,
"maxLength": 100
}
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,81 @@
{
"$id": "https://schemas.nexus.local/nexus-identity.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Nexus Profile",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
},
"totp": {
"account_name": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"name": {
"type": "object",
"properties": {
"first": {
"type": "string",
"title": "First Name",
"minLength": 1,
"maxLength": 100
},
"last": {
"type": "string",
"title": "Last Name",
"minLength": 1,
"maxLength": 100
}
},
"required": ["first", "last"]
},
"phone": {
"type": "string",
"title": "Phone Number",
"pattern": "^[0-9\\s+()-]*$",
"maxLength": 30
},
"profile_type": {
"type": "string",
"title": "Profile Type",
"enum": ["team", "customer"],
"description": "Determines whether this is a team member or customer profile"
}
},
"required": ["email", "name", "profile_type"],
"additionalProperties": false
},
"metadata_public": {
"type": "object",
"properties": {
"django_profile_id": {
"type": "string",
"title": "Nexus Profile ID",
"description": "UUID of the linked Django profile (TeamProfile or CustomerProfile)"
}
}
}
}
}

View File

@ -0,0 +1,162 @@
version: v1.1.0
dsn: env://DSN
serve:
public:
base_url: env://SERVE_PUBLIC_BASE_URL
cors:
enabled: false
# CORS is handled by Oathkeeper proxy to avoid duplicate headers
admin:
base_url: env://SERVE_ADMIN_BASE_URL
selfservice:
default_browser_return_url: https://account.example.com
allowed_return_urls:
- https://account.example.com
- https://auth.example.com
- https://app.example.com
- https://admin.example.com
- http://localhost:5173
- https://local.example.com:5173
- http://localhost:4455
methods:
password:
enabled: true
config:
haveibeenpwned_enabled: true
min_password_length: 8
identifier_similarity_check_enabled: true
totp:
enabled: true
config:
issuer: Nexus Nexus
webauthn:
enabled: true
config:
rp:
display_name: Nexus Nexus
id: example.com
origins:
- https://account.example.com
- https://auth.example.com
- https://app.example.com
- https://admin.example.com
- http://localhost:5173
- https://local.example.com:5173
- http://localhost:4455
link:
enabled: true
code:
enabled: true
flows:
error:
ui_url: https://account.example.com/error
settings:
ui_url: https://account.example.com/settings
privileged_session_max_age: 15m
required_aal: highest_available
recovery:
enabled: true
ui_url: https://account.example.com/recovery
use: code
lifespan: 4h # Extended to give users more time to recover their account
after:
default_browser_return_url: https://account.example.com/settings
verification:
enabled: true
ui_url: https://account.example.com/verification
use: code
lifespan: 24h # Extended to give users a full day to verify their email
after:
default_browser_return_url: https://account.example.com/
logout:
after:
default_browser_return_url: https://account.example.com/login
login:
ui_url: https://account.example.com/login
lifespan: 10m
registration:
lifespan: 10m
ui_url: https://account.example.com/registration
after:
default_browser_return_url: https://account.example.com/
password:
hooks:
- hook: session
log:
level: env://LOG_LEVEL
format: text
leak_sensitive_values: false
secrets:
cookie:
- env://SECRETS_COOKIE
cipher:
- env://SECRETS_CIPHER
default:
- env://SECRETS_DEFAULT
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 12
identity:
default_schema_id: nexus-v2
schemas:
- id: default
url: file:///etc/kratos/identity.schema.json
- id: nexus-v2
url: file:///etc/kratos/identity.v2.schema.json
cookies:
domain: .example.com
same_site: Lax
# Leading dot allows cookies to be shared across subdomains (account.example.com <-> auth.example.com)
session:
lifespan: 24h
earliest_possible_extend: 1h
cookie:
domain: .example.com
same_site: Lax
persistent: true
courier:
smtp:
connection_uri: env://COURIER_SMTP_CONNECTION_URI
from_address: env://COURIER_SMTP_FROM_ADDRESS
from_name: env://COURIER_SMTP_FROM_NAME
templates:
verification_code:
valid:
email:
body:
html: file:///etc/kratos/courier-templates/verification_code_valid.email.body.html.gotmpl
plaintext: file:///etc/kratos/courier-templates/verification_code_valid.email.body.plaintext.gotmpl
subject: file:///etc/kratos/courier-templates/verification_code_valid.email.subject.gotmpl
recovery_code:
valid:
email:
body:
html: file:///etc/kratos/courier-templates/recovery_code_valid.email.body.html.gotmpl
plaintext: file:///etc/kratos/courier-templates/recovery_code_valid.email.body.plaintext.gotmpl
subject: file:///etc/kratos/courier-templates/recovery_code_valid.email.subject.gotmpl

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recover Your Account</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f3f4f6;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 600px; max-width: 100%; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px 40px; text-align: center; border-bottom: 1px solid #e5e7eb;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #111827;">Account Recovery</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
Hi,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
You requested to recover access to your account.
</p>
<p style="margin: 0 0 12px 0; font-size: 16px; line-height: 24px; color: #374151; font-weight: 600;">
Your recovery code is:
</p>
<!-- Code Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 0 0 30px 0;">
<tr>
<td align="center" style="padding: 20px; background-color: #f9fafb; border: 2px solid #e5e7eb; border-radius: 8px;">
<span style="font-size: 32px; font-weight: 700; letter-spacing: 4px; color: #111827; font-family: 'Courier New', monospace;">{{ .RecoveryCode }}</span>
</td>
</tr>
</table>
<p style="margin: 0 0 8px 0; font-size: 16px; line-height: 24px; color: #374151;">
To use this code:
</p>
<ol style="margin: 0 0 20px 0; padding-left: 20px; color: #374151; font-size: 16px; line-height: 24px;">
<li style="margin-bottom: 8px;">Return to the recovery page in your browser</li>
<li style="margin-bottom: 8px;">Enter the code above when prompted</li>
<li style="margin-bottom: 8px;">Follow the instructions to set a new password</li>
</ol>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 20px; color: #6b7280; padding: 12px; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 4px;">
<strong>Note:</strong> This code will expire in 4 hours.
</p>
<p style="margin: 0; font-size: 14px; line-height: 20px; color: #6b7280;">
If you did not request this recovery, please ignore this email.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px 40px; text-align: center; border-top: 1px solid #e5e7eb; background-color: #f9fafb; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #6b7280;">
Best regards,<br>
<strong>Nexus Nexus</strong>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,19 @@
Hi,
You requested to recover access to your account.
Your recovery code is:
{{ .RecoveryCode }}
To use this code:
1. Return to the recovery page in your browser
2. Enter the code above when prompted
3. Follow the instructions to set a new password
This code will expire in 4 hours.
If you did not request this recovery, please ignore this email.
Best regards,
Nexus Nexus

View File

@ -0,0 +1 @@
Recover access to your Nexus Nexus account

View File

@ -0,0 +1,10 @@
Hi,
someone requested to recover your account, but the email address is not associated with any account.
If this was you, please make sure you are using the correct email address.
If this was not you, please ignore this email.
Best regards,
Nexus Nexus

View File

@ -0,0 +1 @@
Account recovery attempted

View File

@ -0,0 +1,12 @@
Hi,
you requested to recover access to your account.
Please click the following link to recover your account:
{{ .RecoveryURL }}
If this was not you, please ignore this email.
Best regards,
Nexus Nexus

View File

@ -0,0 +1 @@
Recover your Nexus Nexus account

View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f3f4f6;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 600px; max-width: 100%; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="padding: 40px 40px 20px 40px; text-align: center; border-bottom: 1px solid #e5e7eb;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #111827;">Verify Your Email</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
Hi,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
Thank you for creating your account!
</p>
<p style="margin: 0 0 12px 0; font-size: 16px; line-height: 24px; color: #374151; font-weight: 600;">
Your verification code is:
</p>
<!-- Code Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 0 0 30px 0;">
<tr>
<td align="center" style="padding: 20px; background-color: #f0fdf4; border: 2px solid #86efac; border-radius: 8px;">
<span style="font-size: 32px; font-weight: 700; letter-spacing: 4px; color: #166534; font-family: 'Courier New', monospace;">{{ .VerificationCode }}</span>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
To verify your email, click the button below:
</p>
<!-- Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 0 0 30px 0;">
<tr>
<td align="center" style="padding: 0;">
<a href="{{ .VerificationURL }}" style="display: inline-block; padding: 14px 32px; background-color: #16a34a; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">Verify Email Address</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #374151;">
Then enter the code above when prompted.
</p>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 20px; color: #6b7280; padding: 12px; background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 4px;">
<strong>Note:</strong> This code will expire in 24 hours.
</p>
<p style="margin: 0; font-size: 14px; line-height: 20px; color: #6b7280;">
If you did not create this account, please ignore this email.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 30px 40px; text-align: center; border-top: 1px solid #e5e7eb; background-color: #f9fafb; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #6b7280;">
Best regards,<br>
<strong>Nexus Nexus</strong>
</p>
</td>
</tr>
</table>
<!-- Alt link for email clients that don't support buttons -->
<table role="presentation" style="width: 600px; max-width: 100%; border-collapse: collapse; margin-top: 20px;">
<tr>
<td style="text-align: center; font-size: 12px; color: #9ca3af;">
<p style="margin: 0;">
If the button doesn't work, copy and paste this link into your browser:<br>
<a href="{{ .VerificationURL }}" style="color: #16a34a; word-break: break-all;">{{ .VerificationURL }}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,20 @@
Hi,
Thank you for creating your account!
Your verification code is:
{{ .VerificationCode }}
To verify your email, click the link below:
{{ .VerificationURL }}
Then enter the code above when prompted.
This code will expire in 24 hours.
If you did not create this account, please ignore this email.
Best regards,
Nexus Nexus

View File

@ -0,0 +1 @@
Please verify your Nexus Nexus account

View File

@ -0,0 +1,122 @@
services:
postgres:
image: postgres:${POSTGRES_VERSION:-14}-alpine
container_name: kratos-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_HOST_AUTH_METHOD: ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- kratos-internal
- ory-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
# Port mapping removed - Kratos only needs internal network access
kratos-migrate:
build:
context: .
dockerfile: Dockerfile
args:
KRATOS_VERSION: ${KRATOS_VERSION}
container_name: kratos-migrate
environment:
DSN: ${KRATOS_DSN}
command: migrate sql -e --yes --config /etc/kratos/kratos.yml
networks:
- kratos-internal
depends_on:
postgres:
condition: service_healthy
restart: on-failure
kratos:
build:
context: .
dockerfile: Dockerfile
args:
KRATOS_VERSION: ${KRATOS_VERSION}
container_name: kratos
restart: unless-stopped
ports:
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
- "${KRATOS_ADMIN_PORT:-4434}:4434"
volumes:
- ./courier-templates:/etc/kratos/courier-templates:ro
environment:
DSN: ${KRATOS_DSN}
SECRETS_DEFAULT: ${SECRETS_DEFAULT}
SECRETS_COOKIE: ${SECRETS_COOKIE}
SECRETS_CIPHER: ${SECRETS_CIPHER}
LOG_LEVEL: ${LOG_LEVEL:-info}
SERVE_PUBLIC_BASE_URL: ${KRATOS_PUBLIC_URL}
SERVE_ADMIN_BASE_URL: ${KRATOS_ADMIN_URL}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
COURIER_SMTP_CONNECTION_URI: ${COURIER_SMTP_CONNECTION_URI}
COURIER_SMTP_FROM_ADDRESS: ${COURIER_SMTP_FROM_ADDRESS}
COURIER_SMTP_FROM_NAME: ${COURIER_SMTP_FROM_NAME}
command: serve --config /etc/kratos/kratos.yml ${KRATOS_DEV_MODE}
networks:
- kratos-internal
- ory-network
depends_on:
kratos-migrate:
condition: service_completed_successfully
postgres:
condition: service_healthy
kratos-courier:
build:
context: .
dockerfile: Dockerfile
args:
KRATOS_VERSION: ${KRATOS_VERSION}
container_name: kratos-courier
restart: unless-stopped
volumes:
- ./courier-templates:/etc/kratos/courier-templates:ro
environment:
DSN: ${KRATOS_DSN}
SECRETS_DEFAULT: ${SECRETS_DEFAULT}
SECRETS_COOKIE: ${SECRETS_COOKIE}
SECRETS_CIPHER: ${SECRETS_CIPHER}
LOG_LEVEL: ${LOG_LEVEL:-info}
SERVE_PUBLIC_BASE_URL: ${KRATOS_PUBLIC_URL}
SERVE_ADMIN_BASE_URL: ${KRATOS_ADMIN_URL}
COURIER_SMTP_CONNECTION_URI: ${COURIER_SMTP_CONNECTION_URI}
COURIER_SMTP_FROM_ADDRESS: ${COURIER_SMTP_FROM_ADDRESS}
COURIER_SMTP_FROM_NAME: ${COURIER_SMTP_FROM_NAME}
command: courier watch --config /etc/kratos/kratos.yml
healthcheck:
test: ["CMD", "true"]
interval: 30s
timeout: 10s
retries: 1
networks:
- kratos-internal
- ory-network
depends_on:
kratos-migrate:
condition: service_completed_successfully
postgres:
condition: service_healthy
networks:
kratos-internal:
driver: bridge
ory-network:
external: true
name: ory-network
volumes:
postgres-data:
driver: local

File diff suppressed because it is too large Load Diff

View File

7
nexus-5-auth-oathkeeper/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.env
.env.local
.env.*.local
*.log
config/id_token.jwks.json
/.env.production
/.env.development

View File

@ -0,0 +1,41 @@
ARG OATHKEEPER_VERSION=v0.40.9
FROM oryd/oathkeeper:${OATHKEEPER_VERSION}
# Switch to root to install packages
USER root
# Install additional tools including gettext for envsubst and su-exec for user switching
RUN apk add --no-cache curl wget gettext su-exec
# Set working directory
WORKDIR /etc/oathkeeper
# Copy configuration templates
COPY config/oathkeeper.yml /etc/oathkeeper/oathkeeper.yml.template
COPY config/access-rules/django.yml /etc/oathkeeper/access-rules/django.yml.template
COPY config/access-rules/kratos-public.yml /etc/oathkeeper/access-rules/kratos-public.yml.template
COPY config/access-rules/kratos-admin.yml /etc/oathkeeper/access-rules/kratos-admin.yml.template
COPY config/id_token.jwks.json /etc/oathkeeper/id_token.jwks.json
# Copy entrypoint script
COPY scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Validate configuration at build time (optional)
RUN oathkeeper help serve || true
# NOTE: Keep as root user so entrypoint can write configs
# Entrypoint will switch to ory user after processing templates
# Expose ports
EXPOSE 4455 4456
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f -s http://localhost:4456/health/ready > /dev/null || exit 1
# Use entrypoint to process templates
ENTRYPOINT ["/entrypoint.sh"]
# Default command
CMD ["serve", "proxy", "--config", "/etc/oathkeeper/oathkeeper.yml"]

View File

@ -0,0 +1,6 @@
# Chat Service - DEPRECATED
# Chat functionality has been integrated directly into Django.
# See django.yml for the WebSocket route at /ws/chat/
#
# This file is kept for reference but contains no active rules.
# The CHAT_URL environment variable in docker-compose.yml is no longer used.

View File

@ -0,0 +1,124 @@
# Static files - Public access (no authentication required)
# Used for email logos, favicons, etc.
- id: "django:static:public"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/static<**>"
methods:
- GET
- HEAD
- OPTIONS
authenticators:
- handler: anonymous
authorizer:
handler: allow
mutators:
- handler: noop
errors:
- handler: json
# GraphQL API - Cookie session authentication
- id: "django:graphql:main"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/graphql/<**>"
methods:
- GET
- POST
- OPTIONS
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json
# Upload endpoints - Cookie session authentication
- id: "django:api:upload:main"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/api/upload/<**>"
methods:
- POST
- OPTIONS
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json
# Media endpoints - Cookie session authentication
- id: "django:api:media:main"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/api/media/<**>"
methods:
- GET
- OPTIONS
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json
# Admin interface - Cookie session authentication
- id: "django:admin:main"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/admin/<**>"
methods:
- GET
- POST
- OPTIONS
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json
# WebSocket Chat - Cookie session authentication
# AI chat assistant using Django Channels
- id: "django:ws:chat"
version: "v0.40.0"
upstream:
url: "${BACKEND_URL}"
preserve_host: false
match:
url: "https://api.example.com/ws/chat/<**>"
methods:
- GET
- OPTIONS
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json

View File

@ -0,0 +1,48 @@
# ====================================
# Kratos Admin API Routes
# ====================================
# Admin API for auth.example.com
- id: "kratos:admin:auth"
version: "v0.40.0"
upstream:
url: "http://kratos:4434"
preserve_host: false
match:
url: "https://auth.example.com/admin/<**>"
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json
# Admin API for localhost
- id: "kratos:admin:localhost"
version: "v0.40.0"
upstream:
url: "http://kratos:4434"
preserve_host: false
match:
url: "http://10.10.10.51:4455/admin/<**>"
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: header
errors:
- handler: json

View File

@ -0,0 +1,115 @@
# ====================================
# Kratos API Routes - Production (auth.example.com)
# ====================================
# Self-service routes for auth.example.com
- id: "kratos:public:self-service:auth"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "https://auth.example.com/self-service/<**>"
methods:
- GET
- POST
- DELETE
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
# WebAuthn JavaScript for auth.example.com
- id: "kratos:public:webauthn:auth"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "https://auth.example.com/.well-known/ory/webauthn.js"
methods:
- GET
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
# Session whoami endpoint for auth.example.com
- id: "kratos:public:whoami:auth"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "https://auth.example.com/sessions/whoami"
methods:
- GET
- POST
- DELETE
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
# ====================================
# Kratos API Routes - Local Development
# ====================================
# Self-service routes for localhost
- id: "kratos:public:self-service:localhost"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "http://10.10.10.51:4455/self-service/<**>"
methods:
- GET
- POST
- DELETE
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
# WebAuthn JavaScript for localhost
- id: "kratos:public:webauthn:localhost"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "http://10.10.10.51:4455/.well-known/ory/webauthn.js"
methods:
- GET
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
# Session whoami endpoint for localhost
- id: "kratos:public:whoami:localhost"
version: "v0.40.0"
upstream:
url: "http://kratos:4433"
preserve_host: false
match:
url: "http://10.10.10.51:4455/sessions/whoami"
methods:
- GET
- POST
- DELETE
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop

View File

@ -0,0 +1,140 @@
serve:
proxy:
port: 4455
trust_forwarded_headers: true
cors:
enabled: true
allowed_origins:
- "https://account.example.com"
- "https://auth.example.com"
- "https://app.example.com"
- "https://admin.example.com"
- "https://api.example.com"
- "http://localhost:4455"
- "http://localhost:5173"
- "https://local.example.com:5173"
- "http://localhost:8000"
allowed_methods:
- GET
- POST
- PUT
- PATCH
- DELETE
allowed_headers:
- Authorization
- Content-Type
- X-Session-Token
- Cookie
- Accept
exposed_headers:
- Content-Type
- Set-Cookie
allow_credentials: true
debug: false
api:
port: 4456
access_rules:
matching_strategy: glob
repositories:
- file:///etc/oathkeeper/access-rules/django.yml
- file:///etc/oathkeeper/access-rules/kratos-public.yml
- file:///etc/oathkeeper/access-rules/kratos-admin.yml
authenticators:
cookie_session:
enabled: true
config:
check_session_url: http://kratos:4433/sessions/whoami
preserve_path: true
extra_from: "@this"
subject_from: "identity.id"
only:
- ory_kratos_session
bearer_token:
enabled: true
config:
check_session_url: http://kratos:4433/sessions/whoami
token_from:
header: Authorization
preserve_path: true
extra_from: "@this"
subject_from: "identity.id"
noop:
enabled: true
anonymous:
enabled: true
config:
subject: guest
authorizers:
allow:
enabled: true
deny:
enabled: true
mutators:
noop:
enabled: true
id_token:
enabled: true
config:
issuer_url: https://auth.example.com/
jwks_url: file:///etc/oathkeeper/id_token.jwks.json
ttl: 1h
claims: |
{
"session": {{ .Extra | toJson }}
}
header:
enabled: true
config:
headers:
# 🔒 SECURITY: Shared secret - must match Django OATHKEEPER_SECRET
X-Oathkeeper-Secret: "${OATHKEEPER_SECRET}"
X-User-ID: "{{ print .Subject }}"
X-User-Email: "{{ print .Extra.identity.traits.email }}"
X-User-First-Name: "{{ print .Extra.identity.traits.name.first }}"
X-User-Last-Name: "{{ print .Extra.identity.traits.name.last }}"
X-User-Phone: "{{ print .Extra.identity.traits.phone }}"
X-User-Profile-Type: "{{ print .Extra.identity.traits.profile_type }}"
X-Django-Profile-ID: "{{ with .Extra.identity.metadata_public }}{{ with .django_profile_id }}{{ . }}{{ end }}{{ end }}"
cookie:
enabled: true
config:
cookies:
user_id: "{{ print .Subject }}"
errors:
fallback:
- json
handlers:
json:
enabled: true
config:
verbose: true
redirect:
enabled: true
config:
to: https://account.example.com/login
when:
- error:
- unauthorized
- forbidden
request:
header:
accept:
- text/html
log:
level: debug
format: text

View File

@ -0,0 +1,30 @@
services:
oathkeeper:
build:
context: .
dockerfile: Dockerfile
args:
OATHKEEPER_VERSION: ${OATHKEEPER_VERSION}
container_name: oathkeeper
restart: unless-stopped
ports:
- "${OATHKEEPER_PROXY_PORT:-4455}:4455"
- "${OATHKEEPER_API_PORT:-4456}:4456"
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
KRATOS_ADMIN_URL: ${KRATOS_ADMIN_URL}
KRATOS_PUBLIC_URL: ${KRATOS_PUBLIC_URL}
BACKEND_URL: ${BACKEND_URL}
OATHKEEPER_SECRET: ${OATHKEEPER_SECRET}
networks:
- ory-network
healthcheck:
test: ["CMD", "curl", "-f", "-s", "http://localhost:4456/health/ready"]
interval: 10s
timeout: 5s
retries: 5
networks:
ory-network:
external: true
name: ory-network

View File

@ -0,0 +1,588 @@
{
"components": {
"responses": {
"emptyResponse": {
"description": "An empty response"
},
"rule": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/rule"
}
}
},
"description": "A rule"
},
"rules": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/rule"
},
"type": "array"
}
}
},
"description": "A list of rules"
}
},
"schemas": {
"UUID": {
"format": "uuid4",
"type": "string"
},
"Upstream": {
"properties": {
"preserve_host": {
"description": "PreserveHost, if false (the default), tells ORY Oathkeeper to set the upstream request's Host header to the\nhostname of the API's upstream's URL. Setting this flag to true instructs ORY Oathkeeper not to do so.",
"type": "boolean"
},
"strip_path": {
"description": "StripPath if set, replaces the provided path prefix when forwarding the requested URL to the upstream URL.",
"type": "string"
},
"url": {
"description": "URL is the URL the request will be proxied to.",
"type": "string"
}
},
"type": "object"
},
"genericError": {
"description": "The standard error format",
"properties": {
"code": {
"format": "int64",
"type": "integer"
},
"details": {
"items": {
"additionalProperties": {},
"type": "object"
},
"type": "array"
},
"message": {
"type": "string"
},
"reason": {
"type": "string"
},
"request": {
"type": "string"
},
"status": {
"type": "string"
}
},
"type": "object"
},
"healthNotReadyStatus": {
"properties": {
"errors": {
"additionalProperties": {
"type": "string"
},
"description": "Errors contains a list of errors that caused the not ready status.",
"type": "object"
}
},
"title": "The not ready status of the service.",
"type": "object"
},
"healthStatus": {
"properties": {
"status": {
"description": "Status always contains \"ok\".",
"type": "string"
}
},
"title": "The health status of the service.",
"type": "object"
},
"jsonWebKey": {
"properties": {
"alg": {
"description": "The \"alg\" (algorithm) parameter identifies the algorithm intended for\nuse with the key. The values used should either be registered in the\nIANA \"JSON Web Signature and Encryption Algorithms\" registry\nestablished by [JWA] or be a value that contains a Collision-\nResistant Name.",
"type": "string"
},
"crv": {
"type": "string"
},
"d": {
"type": "string"
},
"dp": {
"type": "string"
},
"dq": {
"type": "string"
},
"e": {
"type": "string"
},
"k": {
"type": "string"
},
"kid": {
"description": "The \"kid\" (key ID) parameter is used to match a specific key. This\nis used, for instance, to choose among a set of keys within a JWK Set\nduring key rollover. The structure of the \"kid\" value is\nunspecified. When \"kid\" values are used within a JWK Set, different\nkeys within the JWK Set SHOULD use distinct \"kid\" values. (One\nexample in which different keys might use the same \"kid\" value is if\nthey have different \"kty\" (key type) values but are considered to be\nequivalent alternatives by the application using them.) The \"kid\"\nvalue is a case-sensitive string.",
"type": "string"
},
"kty": {
"description": "The \"kty\" (key type) parameter identifies the cryptographic algorithm\nfamily used with the key, such as \"RSA\" or \"EC\". \"kty\" values should\neither be registered in the IANA \"JSON Web Key Types\" registry\nestablished by [JWA] or be a value that contains a Collision-\nResistant Name. The \"kty\" value is a case-sensitive string.",
"type": "string"
},
"n": {
"type": "string"
},
"p": {
"type": "string"
},
"q": {
"type": "string"
},
"qi": {
"type": "string"
},
"use": {
"description": "The \"use\" (public key use) parameter identifies the intended use of\nthe public key. The \"use\" parameter is employed to indicate whether\na public key is used for encrypting data or verifying the signature\non data. Values are commonly \"sig\" (signature) or \"enc\" (encryption).",
"type": "string"
},
"x": {
"type": "string"
},
"x5c": {
"description": "The \"x5c\" (X.509 certificate chain) parameter contains a chain of one\nor more PKIX certificates [RFC5280]. The certificate chain is\nrepresented as a JSON array of certificate value strings. Each\nstring in the array is a base64-encoded (Section 4 of [RFC4648] --\nnot base64url-encoded) DER [ITU.X690.1994] PKIX certificate value.\nThe PKIX certificate containing the key value MUST be the first\ncertificate.",
"items": {
"type": "string"
},
"type": "array"
},
"y": {
"type": "string"
}
},
"type": "object"
},
"jsonWebKeySet": {
"properties": {
"keys": {
"description": "The value of the \"keys\" parameter is an array of JWK values. By\ndefault, the order of the JWK values within the array does not imply\nan order of preference among them, although applications of JWK Sets\ncan choose to assign a meaning to the order for their purposes, if\ndesired.",
"items": {
"$ref": "#/components/schemas/jsonWebKey"
},
"type": "array"
}
},
"type": "object"
},
"rule": {
"properties": {
"authenticators": {
"description": "Authenticators is a list of authentication handlers that will try and authenticate the provided credentials.\nAuthenticators are checked iteratively from index 0 to n and if the first authenticator to return a positive\nresult will be the one used.\n\nIf you want the rule to first check a specific authenticator before \"falling back\" to others, have that authenticator\nas the first item in the array.",
"items": {
"$ref": "#/components/schemas/ruleHandler"
},
"type": "array"
},
"authorizer": {
"$ref": "#/components/schemas/ruleHandler"
},
"description": {
"description": "Description is a human readable description of this rule.",
"type": "string"
},
"id": {
"description": "ID is the unique id of the rule. It can be at most 190 characters long, but the layout of the ID is up to you.\nYou will need this ID later on to update or delete the rule.",
"type": "string"
},
"match": {
"$ref": "#/components/schemas/ruleMatch"
},
"mutators": {
"description": "Mutators is a list of mutation handlers that transform the HTTP request. A common use case is generating a new set\nof credentials (e.g. JWT) which then will be forwarded to the upstream server.\n\nMutations are performed iteratively from index 0 to n and should all succeed in order for the HTTP request to be forwarded.",
"items": {
"$ref": "#/components/schemas/ruleHandler"
},
"type": "array"
},
"upstream": {
"$ref": "#/components/schemas/Upstream"
}
},
"title": "swaggerRule is a single rule that will get checked on every HTTP request.",
"type": "object"
},
"ruleHandler": {
"properties": {
"config": {
"description": "Config contains the configuration for the handler. Please read the user\nguide for a complete list of each handler's available settings."
},
"handler": {
"description": "Handler identifies the implementation which will be used to handle this specific request. Please read the user\nguide for a complete list of available handlers.",
"type": "string"
}
},
"type": "object"
},
"ruleMatch": {
"properties": {
"methods": {
"description": "An array of HTTP methods (e.g. GET, POST, PUT, DELETE, ...). When ORY Oathkeeper searches for rules\nto decide what to do with an incoming request to the proxy server, it compares the HTTP method of the incoming\nrequest with the HTTP methods of each rules. If a match is found, the rule is considered a partial match.\nIf the matchesUrl field is satisfied as well, the rule is considered a full match.",
"items": {
"type": "string"
},
"type": "array"
},
"url": {
"description": "This field represents the URL pattern this rule matches. When ORY Oathkeeper searches for rules\nto decide what to do with an incoming request to the proxy server, it compares the full request URL\n(e.g. https://mydomain.com/api/resource) without query parameters of the incoming\nrequest with this field. If a match is found, the rule is considered a partial match.\nIf the matchesMethods field is satisfied as well, the rule is considered a full match.\n\nYou can use regular expressions in this field to match more than one url. Regular expressions are encapsulated in\nbrackets < and >. The following example matches all paths of the domain `mydomain.com`: `https://mydomain.com/<.*>`.",
"type": "string"
}
},
"type": "object"
},
"unexpectedError": {
"type": "string"
},
"version": {
"properties": {
"version": {
"description": "Version is the service's version.",
"type": "string"
}
},
"type": "object"
}
}
},
"info": {
"contact": {
"email": "hi@ory.sh"
},
"description": "Documentation for all of Ory Oathkeeper's APIs.\n",
"license": {
"name": "Apache 2.0"
},
"title": "Ory Oathkeeper API",
"version": ""
},
"openapi": "3.0.3",
"paths": {
"/.well-known/jwks.json": {
"get": {
"description": "This endpoint returns cryptographic keys that are required to, for example, verify signatures of ID Tokens.",
"operationId": "getWellKnownJSONWebKeys",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/jsonWebKeySet"
}
}
},
"description": "jsonWebKeySet"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
}
},
"summary": "Lists Cryptographic Keys",
"tags": [
"api"
]
}
},
"/decisions": {
"get": {
"description": "> This endpoint works with all HTTP Methods (GET, POST, PUT, ...) and matches every path prefixed with /decisions.\n\nThis endpoint mirrors the proxy capability of ORY Oathkeeper's proxy functionality but instead of forwarding the\nrequest to the upstream server, returns 200 (request should be allowed), 401 (unauthorized), or 403 (forbidden)\nstatus codes. This endpoint can be used to integrate with other API Proxies like Ambassador, Kong, Envoy, and many more.",
"operationId": "decisions",
"responses": {
"200": {
"$ref": "#/components/responses/emptyResponse"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
}
},
"summary": "Access Control Decision API",
"tags": [
"api"
]
}
},
"/health/alive": {
"get": {
"description": "This endpoint returns a HTTP 200 status code when Ory Oathkeeper is accepting incoming\nHTTP requests. This status does currently not include checks whether the database connection is working.\n\nIf the service supports TLS Edge Termination, this endpoint does not require the\n`X-Forwarded-Proto` header to be set.\n\nBe aware that if you are running multiple nodes of this service, the health status will never\nrefer to the cluster state, only to a single instance.",
"operationId": "isAlive",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"status": {
"description": "Always \"ok\".",
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
}
}
},
"description": "Ory Oathkeeper is ready to accept connections."
},
"default": {
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
},
"description": "Unexpected error"
}
},
"summary": "Check HTTP Server Status",
"tags": [
"metadata"
]
}
},
"/health/ready": {
"get": {
"description": "This endpoint returns a HTTP 200 status code when Ory Oathkeeper is up running and the environment dependencies (e.g.\nthe database) are responsive as well.\n\nIf the service supports TLS Edge Termination, this endpoint does not require the\n`X-Forwarded-Proto` header to be set.\n\nBe aware that if you are running multiple nodes of Ory Oathkeeper, the health status will never\nrefer to the cluster state, only to a single instance.",
"operationId": "isReady",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"status": {
"description": "Always \"ok\".",
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
}
}
},
"description": "Ory Oathkeeper is ready to accept requests."
},
"503": {
"content": {
"application/json": {
"schema": {
"properties": {
"errors": {
"additionalProperties": {
"type": "string"
},
"description": "Errors contains a list of errors that caused the not ready status.",
"type": "object"
}
},
"required": [
"errors"
],
"type": "object"
}
}
},
"description": "Ory Kratos is not yet ready to accept requests."
},
"default": {
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
},
"description": "Unexpected error"
}
},
"summary": "Check HTTP Server and Database Status",
"tags": [
"metadata"
]
}
},
"/rules": {
"get": {
"description": "This method returns an array of all rules that are stored in the backend. This is useful if you want to get a full\nview of what rules you have currently in place.",
"operationId": "listRules",
"parameters": [
{
"description": "The maximum amount of rules returned.",
"in": "query",
"name": "limit",
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "The offset from where to start looking.",
"in": "query",
"name": "offset",
"schema": {
"format": "int64",
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/rules"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
}
},
"summary": "List All Rules",
"tags": [
"api"
]
}
},
"/rules/{id}": {
"get": {
"description": "Use this method to retrieve a rule from the storage. If it does not exist you will receive a 404 error.",
"operationId": "getRule",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/rule"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/genericError"
}
}
},
"description": "genericError"
}
},
"summary": "Retrieve a Rule",
"tags": [
"api"
]
}
},
"/version": {
"get": {
"description": "This endpoint returns the version of Ory Oathkeeper.\n\nIf the service supports TLS Edge Termination, this endpoint does not require the\n`X-Forwarded-Proto` header to be set.\n\nBe aware that if you are running multiple nodes of this service, the version will never\nrefer to the cluster state, only to a single instance.",
"operationId": "getVersion",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"version": {
"description": "The version of Ory Oathkeeper.",
"type": "string"
}
},
"required": [
"version"
],
"type": "object"
}
}
},
"description": "Returns the Ory Oathkeeper version."
}
},
"summary": "Return Running Software Version.",
"tags": [
"metadata"
]
}
}
},
"x-forwarded-proto": "string",
"x-request-id": "string"
}

View File

@ -0,0 +1,30 @@
#!/bin/sh
set -e
echo "Processing Oathkeeper configuration templates..."
# Substitute environment variables in oathkeeper.yml
envsubst < /etc/oathkeeper/oathkeeper.yml.template > /etc/oathkeeper/oathkeeper.yml
rm /etc/oathkeeper/oathkeeper.yml.template
echo "✓ Processed Oathkeeper config"
# Substitute environment variables for access rules
envsubst < /etc/oathkeeper/access-rules/django.yml.template > /etc/oathkeeper/access-rules/django.yml
rm /etc/oathkeeper/access-rules/django.yml.template
echo "✓ Processed Django access rules"
envsubst < /etc/oathkeeper/access-rules/kratos-public.yml.template > /etc/oathkeeper/access-rules/kratos-public.yml
rm /etc/oathkeeper/access-rules/kratos-public.yml.template
echo "✓ Processed Kratos public access rules"
envsubst < /etc/oathkeeper/access-rules/kratos-admin.yml.template > /etc/oathkeeper/access-rules/kratos-admin.yml
rm /etc/oathkeeper/access-rules/kratos-admin.yml.template
echo "✓ Processed Kratos admin access rules"
echo "✓ Processed Oathkeeper access rules"
# Set proper ownership for ory user
chown -R ory:ory /etc/oathkeeper
echo "Starting Oathkeeper as ory user..."
# Switch to ory user and execute the CMD passed to the container
exec su-exec ory oathkeeper "$@"

View File

@ -0,0 +1,26 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_DIR="$SCRIPT_DIR/../config"
JWKS_FILE="$CONFIG_DIR/id_token.jwks.json"
# Check if JWKS file already has keys
if [ -f "$JWKS_FILE" ]; then
KEY_COUNT=$(cat "$JWKS_FILE" | jq '.keys | length' 2>/dev/null || echo "0")
if [ "$KEY_COUNT" -gt 0 ]; then
echo "JWKS keys already exist at $JWKS_FILE"
echo "If you want to regenerate, delete the file first."
exit 0
fi
fi
echo "Generating JWKS keys..."
docker run --rm oryd/oathkeeper:v0.40.9 credentials generate --alg RS256 > "$JWKS_FILE"
if [ $? -eq 0 ]; then
echo "✓ JWKS keys successfully generated at $JWKS_FILE"
else
echo "✗ Failed to generate JWKS keys"
exit 1
fi