public-ready-init
This commit is contained in:
commit
a9fa1a0a0f
14
.env.example
Normal file
14
.env.example
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/ory-sdk/
|
||||||
|
.idea
|
||||||
402
ORY_SDK_MIGRATION_GUIDE.md
Normal file
402
ORY_SDK_MIGRATION_GUIDE.md
Normal 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
597
PRODUCTION-DEPLOYMENT.md
Normal 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
273
README.md
Normal 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.
|
||||||
32
nexus-5-auth-frontend/.dockerignore
Normal file
32
nexus-5-auth-frontend/.dockerignore
Normal 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
24
nexus-5-auth-frontend/.gitignore
vendored
Normal 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
|
||||||
1
nexus-5-auth-frontend/.npmrc
Normal file
1
nexus-5-auth-frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
9
nexus-5-auth-frontend/.prettierignore
Normal file
9
nexus-5-auth-frontend/.prettierignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
16
nexus-5-auth-frontend/.prettierrc
Normal file
16
nexus-5-auth-frontend/.prettierrc
Normal 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"
|
||||||
|
}
|
||||||
63
nexus-5-auth-frontend/Dockerfile
Normal file
63
nexus-5-auth-frontend/Dockerfile
Normal 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"]
|
||||||
28
nexus-5-auth-frontend/docker-compose.yml
Normal file
28
nexus-5-auth-frontend/docker-compose.yml
Normal 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
|
||||||
41
nexus-5-auth-frontend/eslint.config.js
Normal file
41
nexus-5-auth-frontend/eslint.config.js
Normal 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
4925
nexus-5-auth-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
nexus-5-auth-frontend/package.json
Normal file
45
nexus-5-auth-frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
314
nexus-5-auth-frontend/src/app.css
Normal file
314
nexus-5-auth-frontend/src/app.css
Normal 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
13
nexus-5-auth-frontend/src/app.d.ts
vendored
Normal 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 {};
|
||||||
12
nexus-5-auth-frontend/src/app.html
Normal file
12
nexus-5-auth-frontend/src/app.html
Normal 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>
|
||||||
1
nexus-5-auth-frontend/src/lib/assets/favicon.svg
Normal file
1
nexus-5-auth-frontend/src/lib/assets/favicon.svg
Normal 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 |
284
nexus-5-auth-frontend/src/lib/components/FlowForm.svelte
Normal file
284
nexus-5-auth-frontend/src/lib/components/FlowForm.svelte
Normal 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>
|
||||||
243
nexus-5-auth-frontend/src/lib/components/FormField.svelte
Normal file
243
nexus-5-auth-frontend/src/lib/components/FormField.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
96
nexus-5-auth-frontend/src/lib/flows.ts
Normal file
96
nexus-5-auth-frontend/src/lib/flows.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
11
nexus-5-auth-frontend/src/lib/kratos-server.ts
Normal file
11
nexus-5-auth-frontend/src/lib/kratos-server.ts
Normal 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'
|
||||||
|
})
|
||||||
|
);
|
||||||
24
nexus-5-auth-frontend/src/lib/kratos.ts
Normal file
24
nexus-5-auth-frontend/src/lib/kratos.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
79
nexus-5-auth-frontend/src/lib/stores/theme.svelte.ts
Normal file
79
nexus-5-auth-frontend/src/lib/stores/theme.svelte.ts
Normal 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();
|
||||||
52
nexus-5-auth-frontend/src/lib/utils.ts
Normal file
52
nexus-5-auth-frontend/src/lib/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
23
nexus-5-auth-frontend/src/routes/+layout.server.ts
Normal file
23
nexus-5-auth-frontend/src/routes/+layout.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
187
nexus-5-auth-frontend/src/routes/+layout.svelte
Normal file
187
nexus-5-auth-frontend/src/routes/+layout.svelte
Normal 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>
|
||||||
135
nexus-5-auth-frontend/src/routes/+page.svelte
Normal file
135
nexus-5-auth-frontend/src/routes/+page.svelte
Normal 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}
|
||||||
31
nexus-5-auth-frontend/src/routes/admin/+page.server.ts
Normal file
31
nexus-5-auth-frontend/src/routes/admin/+page.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
1444
nexus-5-auth-frontend/src/routes/admin/+page.svelte
Normal file
1444
nexus-5-auth-frontend/src/routes/admin/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
22
nexus-5-auth-frontend/src/routes/error/+page.server.ts
Normal file
22
nexus-5-auth-frontend/src/routes/error/+page.server.ts
Normal 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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
49
nexus-5-auth-frontend/src/routes/error/+page.svelte
Normal file
49
nexus-5-auth-frontend/src/routes/error/+page.svelte
Normal 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>
|
||||||
77
nexus-5-auth-frontend/src/routes/login/+page.svelte
Normal file
77
nexus-5-auth-frontend/src/routes/login/+page.svelte
Normal 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>
|
||||||
56
nexus-5-auth-frontend/src/routes/logout/+server.ts
Normal file
56
nexus-5-auth-frontend/src/routes/logout/+server.ts
Normal 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');
|
||||||
|
};
|
||||||
76
nexus-5-auth-frontend/src/routes/recovery/+page.server.ts
Normal file
76
nexus-5-auth-frontend/src/routes/recovery/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
49
nexus-5-auth-frontend/src/routes/recovery/+page.svelte
Normal file
49
nexus-5-auth-frontend/src/routes/recovery/+page.svelte
Normal 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>
|
||||||
71
nexus-5-auth-frontend/src/routes/registration/+page.svelte
Normal file
71
nexus-5-auth-frontend/src/routes/registration/+page.svelte
Normal 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>
|
||||||
21
nexus-5-auth-frontend/src/routes/settings/+page.server.ts
Normal file
21
nexus-5-auth-frontend/src/routes/settings/+page.server.ts
Normal 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 {};
|
||||||
|
};
|
||||||
82
nexus-5-auth-frontend/src/routes/settings/+page.svelte
Normal file
82
nexus-5-auth-frontend/src/routes/settings/+page.svelte
Normal 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>
|
||||||
@ -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 };
|
||||||
|
};
|
||||||
42
nexus-5-auth-frontend/src/routes/verification/+page.svelte
Normal file
42
nexus-5-auth-frontend/src/routes/verification/+page.svelte
Normal 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>
|
||||||
3
nexus-5-auth-frontend/static/robots.txt
Normal file
3
nexus-5-auth-frontend/static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
12
nexus-5-auth-frontend/svelte.config.js
Normal file
12
nexus-5-auth-frontend/svelte.config.js
Normal 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;
|
||||||
19
nexus-5-auth-frontend/tsconfig.json
Normal file
19
nexus-5-auth-frontend/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
7
nexus-5-auth-frontend/vite.config.ts
Normal file
7
nexus-5-auth-frontend/vite.config.ts
Normal 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()]
|
||||||
|
});
|
||||||
0
nexus-5-auth-kratos/.dockerignore
Normal file
0
nexus-5-auth-kratos/.dockerignore
Normal file
5
nexus-5-auth-kratos/.gitignore
vendored
Normal file
5
nexus-5-auth-kratos/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
|
/.env.production
|
||||||
|
/.env.development
|
||||||
32
nexus-5-auth-kratos/Dockerfile
Normal file
32
nexus-5-auth-kratos/Dockerfile
Normal 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"]
|
||||||
58
nexus-5-auth-kratos/config/identity.schema.json
Normal file
58
nexus-5-auth-kratos/config/identity.schema.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
nexus-5-auth-kratos/config/identity.v2.schema.json
Normal file
81
nexus-5-auth-kratos/config/identity.v2.schema.json
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
nexus-5-auth-kratos/config/kratos.yml
Normal file
162
nexus-5-auth-kratos/config/kratos.yml
Normal 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
|
||||||
@ -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>
|
||||||
@ -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
|
||||||
@ -0,0 +1 @@
|
|||||||
|
Recover access to your Nexus Nexus account
|
||||||
@ -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
|
||||||
@ -0,0 +1 @@
|
|||||||
|
Account recovery attempted
|
||||||
@ -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
|
||||||
@ -0,0 +1 @@
|
|||||||
|
Recover your Nexus Nexus account
|
||||||
@ -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>
|
||||||
@ -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
|
||||||
@ -0,0 +1 @@
|
|||||||
|
Please verify your Nexus Nexus account
|
||||||
122
nexus-5-auth-kratos/docker-compose.yml
Normal file
122
nexus-5-auth-kratos/docker-compose.yml
Normal 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
|
||||||
7706
nexus-5-auth-kratos/openapi.json
Normal file
7706
nexus-5-auth-kratos/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
0
nexus-5-auth-oathkeeper/.dockerignore
Normal file
0
nexus-5-auth-oathkeeper/.dockerignore
Normal file
7
nexus-5-auth-oathkeeper/.gitignore
vendored
Normal file
7
nexus-5-auth-oathkeeper/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
config/id_token.jwks.json
|
||||||
|
/.env.production
|
||||||
|
/.env.development
|
||||||
41
nexus-5-auth-oathkeeper/Dockerfile
Normal file
41
nexus-5-auth-oathkeeper/Dockerfile
Normal 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"]
|
||||||
6
nexus-5-auth-oathkeeper/config/access-rules/chat.yml
Normal file
6
nexus-5-auth-oathkeeper/config/access-rules/chat.yml
Normal 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.
|
||||||
124
nexus-5-auth-oathkeeper/config/access-rules/django.yml
Normal file
124
nexus-5-auth-oathkeeper/config/access-rules/django.yml
Normal 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
|
||||||
48
nexus-5-auth-oathkeeper/config/access-rules/kratos-admin.yml
Normal file
48
nexus-5-auth-oathkeeper/config/access-rules/kratos-admin.yml
Normal 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
|
||||||
115
nexus-5-auth-oathkeeper/config/access-rules/kratos-public.yml
Normal file
115
nexus-5-auth-oathkeeper/config/access-rules/kratos-public.yml
Normal 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
|
||||||
140
nexus-5-auth-oathkeeper/config/oathkeeper.yml
Normal file
140
nexus-5-auth-oathkeeper/config/oathkeeper.yml
Normal 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
|
||||||
30
nexus-5-auth-oathkeeper/docker-compose.yml
Normal file
30
nexus-5-auth-oathkeeper/docker-compose.yml
Normal 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
|
||||||
588
nexus-5-auth-oathkeeper/openapi.json
Normal file
588
nexus-5-auth-oathkeeper/openapi.json
Normal 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"
|
||||||
|
}
|
||||||
30
nexus-5-auth-oathkeeper/scripts/entrypoint.sh
Normal file
30
nexus-5-auth-oathkeeper/scripts/entrypoint.sh
Normal 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 "$@"
|
||||||
26
nexus-5-auth-oathkeeper/scripts/generate-jwks.sh
Executable file
26
nexus-5-auth-oathkeeper/scripts/generate-jwks.sh
Executable 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
|
||||||
Loading…
x
Reference in New Issue
Block a user