From e2ed9d7a96cf13fb2b2d93557a7a92fe32f6bf51 Mon Sep 17 00:00:00 2001 From: vishnubyrraju Date: Mon, 5 Jan 2026 21:46:05 +0530 Subject: [PATCH 01/17] Added SSO and User Management --- docker-compose.proxy-example.yml | 159 +++++ docs/QUICK_SETUP.md | 177 +++++ docs/forward-auth-proxy-guide.md | 445 +++++++++++++ package-lock.json | 545 ++++++++++++++-- package.json | 14 +- scripts/migrate_tags.js | 26 + src/app/.well-known/jwks.json/route.ts | 20 + .../.well-known/openid-configuration/route.ts | 20 + src/app/api/.well-known/jwks.json/route.ts | 27 + .../.well-known/openid-configuration/route.ts | 32 + src/app/api/auth/[provider]/callback/route.ts | 196 ++++++ src/app/api/auth/[provider]/login/route.ts | 62 ++ src/app/api/auth/providers/route.ts | 119 ++++ src/app/api/auth/register/route.ts | 6 +- src/app/api/oauth/authorize/route.ts | 204 ++++++ src/app/api/oauth/clients/route.ts | 342 ++++++++++ src/app/api/oauth/introspect/route.ts | 47 ++ src/app/api/oauth/revoke/route.ts | 49 ++ src/app/api/oauth/token/route.ts | 87 +++ src/app/api/oauth/userinfo/route.ts | 99 +++ src/app/api/proxy/[slug]/[...path]/route.ts | 58 ++ src/app/api/proxy/apps/route.ts | 159 +++++ src/app/api/saml/metadata/route.ts | 27 + src/app/api/users/route.ts | 6 +- src/app/login/page.tsx | 43 +- src/app/oauth/consent/page.module.css | 132 ++++ src/app/oauth/consent/page.tsx | 198 ++++++ src/app/onboard/page.tsx | 14 +- src/app/settings/page.module.css | 145 ++++- src/app/settings/page.tsx | 469 +++++++------ src/components/Dashboard.tsx | 24 +- src/components/modals/AddServiceModal.tsx | 20 +- src/components/oauth/AuthProviderManager.tsx | 219 +++++++ src/components/oauth/ClientManager.module.css | 521 +++++++++++++++ src/components/oauth/ClientManager.tsx | 435 +++++++++++++ src/components/proxy/ProxyManager.module.css | 271 ++++++++ src/components/proxy/ProxyManager.tsx | 288 ++++++++ src/components/ui/UserManagement.module.css | 26 + src/components/ui/UserManagement.tsx | 64 +- src/lib/auth-providers.ts | 139 ++++ src/lib/auth.ts | 6 +- src/lib/db-oauth.ts | 382 +++++++++++ src/lib/db.ts | 100 ++- src/lib/oauth/oidc-tokens.ts | 95 +++ src/lib/oauth/provider.ts | 615 ++++++++++++++++++ src/lib/oauth/types.ts | 142 ++++ src/lib/oidc/client-discovery.ts | 37 ++ src/lib/oidc/discovery.ts | 80 +++ src/lib/oidc/jwt.ts | 214 ++++++ src/lib/proxy/auth-proxy.ts | 176 +++++ src/lib/proxy/db-proxy.ts | 167 +++++ src/lib/proxy/types.ts | 29 + src/lib/saml/certificates.ts | 163 +++++ src/lib/saml/db-saml.ts | 178 +++++ src/lib/saml/metadata.ts | 89 +++ src/lib/saml/types.ts | 38 ++ src/lib/schema.sql | 4 + src/lib/schema_federated.sql | 33 + src/lib/schema_oauth.sql | 129 ++++ src/lib/validation.ts | 4 + src/proxy.ts | 2 +- src/types/index.ts | 1 + src/types/node-jose.d.ts | 28 + 63 files changed, 8339 insertions(+), 307 deletions(-) create mode 100644 docker-compose.proxy-example.yml create mode 100644 docs/QUICK_SETUP.md create mode 100644 docs/forward-auth-proxy-guide.md create mode 100644 scripts/migrate_tags.js create mode 100644 src/app/.well-known/jwks.json/route.ts create mode 100644 src/app/.well-known/openid-configuration/route.ts create mode 100644 src/app/api/.well-known/jwks.json/route.ts create mode 100644 src/app/api/.well-known/openid-configuration/route.ts create mode 100644 src/app/api/auth/[provider]/callback/route.ts create mode 100644 src/app/api/auth/[provider]/login/route.ts create mode 100644 src/app/api/auth/providers/route.ts create mode 100644 src/app/api/oauth/authorize/route.ts create mode 100644 src/app/api/oauth/clients/route.ts create mode 100644 src/app/api/oauth/introspect/route.ts create mode 100644 src/app/api/oauth/revoke/route.ts create mode 100644 src/app/api/oauth/token/route.ts create mode 100644 src/app/api/oauth/userinfo/route.ts create mode 100644 src/app/api/proxy/[slug]/[...path]/route.ts create mode 100644 src/app/api/proxy/apps/route.ts create mode 100644 src/app/api/saml/metadata/route.ts create mode 100644 src/app/oauth/consent/page.module.css create mode 100644 src/app/oauth/consent/page.tsx create mode 100644 src/components/oauth/AuthProviderManager.tsx create mode 100644 src/components/oauth/ClientManager.module.css create mode 100644 src/components/oauth/ClientManager.tsx create mode 100644 src/components/proxy/ProxyManager.module.css create mode 100644 src/components/proxy/ProxyManager.tsx create mode 100644 src/lib/auth-providers.ts create mode 100644 src/lib/db-oauth.ts create mode 100644 src/lib/oauth/oidc-tokens.ts create mode 100644 src/lib/oauth/provider.ts create mode 100644 src/lib/oauth/types.ts create mode 100644 src/lib/oidc/client-discovery.ts create mode 100644 src/lib/oidc/discovery.ts create mode 100644 src/lib/oidc/jwt.ts create mode 100644 src/lib/proxy/auth-proxy.ts create mode 100644 src/lib/proxy/db-proxy.ts create mode 100644 src/lib/proxy/types.ts create mode 100644 src/lib/saml/certificates.ts create mode 100644 src/lib/saml/db-saml.ts create mode 100644 src/lib/saml/metadata.ts create mode 100644 src/lib/saml/types.ts create mode 100644 src/lib/schema_federated.sql create mode 100644 src/lib/schema_oauth.sql create mode 100644 src/types/node-jose.d.ts diff --git a/docker-compose.proxy-example.yml b/docker-compose.proxy-example.yml new file mode 100644 index 0000000..7a03b2f --- /dev/null +++ b/docker-compose.proxy-example.yml @@ -0,0 +1,159 @@ +version: "3.8" + +# Example: Protecting applications with oauth2-proxy + Atom OIDC +# Each app gets its own oauth2-proxy sidecar container + +services: + # ============================================================================ + # Atom Dashboard (SSO Provider) + # ============================================================================ + atom: + build: . + container_name: atom-dashboard + ports: + - "3000:3000" + environment: + - OAUTH_ISSUER_URL=http://atom:3000 # Change to your domain in production + - DATA_DIR=/app/data + volumes: + - ./data:/app/data + networks: + media_stack: + ipv4_address: 172.30.0.2 + restart: unless-stopped + + # ============================================================================ + # Example 1: Grafana with oauth2-proxy + # ============================================================================ + grafana: + image: grafana/grafana:latest + container_name: grafana + environment: + # Configure Grafana to trust proxy headers + - GF_AUTH_PROXY_ENABLED=true + - GF_AUTH_PROXY_HEADER_NAME=X-Forwarded-User + - GF_AUTH_PROXY_AUTO_SIGN_UP=true + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana_data:/var/lib/grafana + networks: + media_stack: + ipv4_address: 172.30.0.10 + + grafana-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + container_name: grafana-auth + command: + - --provider=oidc + - --oidc-issuer-url=http://172.30.0.2:3000 # Atom's IP + - --client-id=CHANGE_ME # Create in Atom UI + - --client-secret=CHANGE_ME # Create in Atom UI + - --cookie-secret=CHANGE_ME_32_CHARS_RANDOM # Generate with: openssl rand -base64 32 + - --redirect-url=http://localhost:8080/oauth2/callback # Change to your domain + - --upstream=http://172.30.0.10:3000 # Grafana's IP + - --email-domain=* + - --pass-user-headers=true + - --pass-access-token=true + - --cookie-secure=false # Set to true in production with HTTPS + - --http-address=0.0.0.0:4180 + ports: + - "8080:4180" + networks: + media_stack: + ipv4_address: 172.30.0.11 + depends_on: + - atom + - grafana + restart: unless-stopped + + # ============================================================================ + # Example 2: Sonarr with oauth2-proxy + # ============================================================================ + sonarr: + image: linuxserver/sonarr:latest + container_name: sonarr + environment: + - PUID=1000 + - PGID=1000 + - TZ=Asia/Kolkata + volumes: + - ./sonarr:/config + networks: + media_stack: + ipv4_address: 172.30.0.12 + + sonarr-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + container_name: sonarr-auth + command: + - --provider=oidc + - --oidc-issuer-url=http://172.30.0.2:3000 + - --client-id=CHANGE_ME_SONARR + - --client-secret=CHANGE_ME_SONARR + - --cookie-secret=CHANGE_ME_32_CHARS_RANDOM_DIFFERENT + - --redirect-url=http://localhost:8989/oauth2/callback + - --upstream=http://172.30.0.12:8989 + - --email-domain=* + - --pass-user-headers=true + - --cookie-secure=false + - --http-address=0.0.0.0:4180 + ports: + - "8989:4180" + networks: + media_stack: + ipv4_address: 172.30.0.13 + depends_on: + - atom + - sonarr + restart: unless-stopped + + # ============================================================================ + # Example 3: Radarr with oauth2-proxy + # ============================================================================ + radarr: + image: linuxserver/radarr:latest + container_name: radarr + environment: + - PUID=1000 + - PGID=1000 + - TZ=Asia/Kolkata + volumes: + - ./radarr:/config + networks: + media_stack: + ipv4_address: 172.30.0.14 + + radarr-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + container_name: radarr-auth + command: + - --provider=oidc + - --oidc-issuer-url=http://172.30.0.2:3000 + - --client-id=CHANGE_ME_RADARR + - --client-secret=CHANGE_ME_RADARR + - --cookie-secret=CHANGE_ME_32_CHARS_RANDOM_YET_ANOTHER + - --redirect-url=http://localhost:7878/oauth2/callback + - --upstream=http://172.30.0.14:7878 + - --email-domain=* + - --pass-user-headers=true + - --cookie-secure=false + - --http-address=0.0.0.0:4180 + ports: + - "7878:4180" + networks: + media_stack: + ipv4_address: 172.30.0.15 + depends_on: + - atom + - radarr + restart: unless-stopped + +volumes: + grafana_data: + +networks: + media_stack: + driver: bridge + ipam: + config: + - subnet: 172.30.0.0/24 diff --git a/docs/QUICK_SETUP.md b/docs/QUICK_SETUP.md new file mode 100644 index 0000000..965a0c1 --- /dev/null +++ b/docs/QUICK_SETUP.md @@ -0,0 +1,177 @@ +# Quick Setup Guide - oauth2-proxy with Atom + +## Prerequisites + +1. **Atom dashboard running** on `http://atom:3000` (or your domain) +2. **Docker & Docker Compose** installed + +## Setup Steps + +### Step 1: Generate Cookie Secret + +For each oauth2-proxy container, generate a unique cookie secret: + +```bash +openssl rand -base64 32 | tr -- '+/' '-_' +``` + +Example output: `abc123DEF456ghi789JKL012mno345PQR=` + +### Step 2: Create OAuth Clients in Atom + +For each application, create an OAuth client: + +**Via Atom UI:** +1. Go to `http://localhost:3000/settings` +2. Scroll to "SSO Provider" section +3. Click "Add Application" +4. Fill in: + - **Name**: `Grafana` (or app name) + - **Redirect URIs**: `http://localhost:8080/oauth2/callback` (match your setup) + - **Allowed Scopes**: Select `openid`, `profile`, `email` +5. Click "Create" +6. **Copy the Client ID and Client Secret** (you'll need these!) + +**Repeat for each app** (Sonarr, Radarr, etc.) + +### Step 3: Update docker-compose.yml + +Replace all `CHANGE_ME` values in `docker-compose.proxy-example.yml`: + +```yaml +grafana-auth: + command: + - --client-id=abc123-from-atom-ui + - --client-secret=secret456-from-atom-ui + - --cookie-secret=xyz789-generated-above + - --redirect-url=http://localhost:8080/oauth2/callback # Your actual URL +``` + +### Step 4: Start Services + +```bash +docker-compose -f docker-compose.proxy-example.yml up -d +``` + +### Step 5: Test + +1. Visit `http://localhost:8080` (Grafana via proxy) +2. You'll be redirected to Atom login +3. Login with your Atom credentials +4. Redirected back to Grafana (auto-logged in!) + +## Access URLs + +After setup: + +- **Atom Dashboard**: `http://localhost:3000` +- **Grafana** (protected): `http://localhost:8080` +- **Sonarr** (protected): `http://localhost:8989` +- **Radarr** (protected): `http://localhost:7878` + +## Adding More Apps + +To protect a new application: + +1. **Add the app service** to docker-compose.yml +2. **Add oauth2-proxy sidecar**: + ```yaml + myapp-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + command: + - --provider=oidc + - --oidc-issuer-url=http://172.30.0.2:3000 + - --client-id=myapp-client + - --client-secret=myapp-secret + - --cookie-secret=unique-32-chars + - --redirect-url=http://localhost:PORT/oauth2/callback + - --upstream=http://myapp:INTERNAL_PORT + - --email-domain=* + - --pass-user-headers=true + ports: + - "PORT:4180" + ``` +3. **Create OAuth client** in Atom UI +4. **Update values** and restart + +## Production Tips + +### Use HTTPS + +```yaml +command: + - --cookie-secure=true + - --redirect-url=https://grafana.yourdomain.com/oauth2/callback +``` + +Update Atom: +```yaml +atom: + environment: + - OAUTH_ISSUER_URL=https://atom.yourdomain.com +``` + +### Restrict Users by Email + +```yaml +command: + - --email-domain=yourdomain.com # Only @yourdomain.com emails +``` + +### Session Duration + +```yaml +command: + - --cookie-expire=12h + - --cookie-refresh=1h +``` + +## Troubleshooting + +### "Invalid client" error + +- Check client ID and secret match what's in Atom +- Verify OAuth client is created in Atom UI + +### Redirect loop + +- Check `redirect-url` matches the public URL +- Ensure `cookie-secure=false` for HTTP (dev) or `true` for HTTPS (prod) + +### "OIDC discovery failed" + +- Verify Atom is running: `http://atom:3000` +- Test discovery: `curl http://atom:3000/.well-known/openid-configuration` + +### Can't access app directly + +- This is expected! App is only accessible via oauth2-proxy +- Access via proxy: `http://localhost:PORT` (not the app's internal port) + +## Environment Variables Alternative + +Instead of command flags, use environment variables: + +```yaml +grafana-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + environment: + - OAUTH2_PROXY_PROVIDER=oidc + - OAUTH2_PROXY_OIDC_ISSUER_URL=http://172.30.0.2:3000 + - OAUTH2_PROXY_CLIENT_ID=abc123 + - OAUTH2_PROXY_CLIENT_SECRET=secret456 + - OAUTH2_PROXY_COOKIE_SECRET=xyz789 + - OAUTH2_PROXY_REDIRECT_URL=http://localhost:8080/oauth2/callback + - OAUTH2_PROXY_UPSTREAMS=http://172.30.0.10:3000 + - OAUTH2_PROXY_EMAIL_DOMAINS=* + - OAUTH2_PROXY_PASS_USER_HEADERS=true +``` + +## Complete Example + +See `docker-compose.proxy-example.yml` for working examples with: +- ✅ Grafana (with proxy header auth) +- ✅ Sonarr (any web app) +- ✅ Radarr (any web app) + +All protected by Atom SSO! diff --git a/docs/forward-auth-proxy-guide.md b/docs/forward-auth-proxy-guide.md new file mode 100644 index 0000000..e08b1bb --- /dev/null +++ b/docs/forward-auth-proxy-guide.md @@ -0,0 +1,445 @@ +# Forward Auth Proxy with Atom OIDC + +## Complete Docker Compose Examples + +This guide shows how to use Atom's OIDC as the SSO provider with oauth2-proxy as a standalone forward authentication proxy (similar to Authentik's proxy). + +--- + +## Architecture + +``` +User Request → oauth2-proxy → Backend App + ↓ + Atom OIDC (SSO) +``` + +**oauth2-proxy** checks if user is authenticated: +- ✅ **Authenticated**: Proxies to backend with user headers +- ❌ **Not authenticated**: Redirects to Atom for login + +--- + +## Example 1: Grafana with Forward Auth + +```yaml +version: '3.8' + +services: + # Atom Dashboard (SSO Provider) + atom: + image: your-atom-image:latest + ports: + - "3000:3000" + environment: + - OAUTH_ISSUER_URL=http://atom:3000 + networks: + - apps + + # Grafana (Backend Application) + grafana: + image: grafana/grafana:latest + environment: + # Configure Grafana to trust proxy headers + - GF_AUTH_PROXY_ENABLED=true + - GF_AUTH_PROXY_HEADER_NAME=X-Forwarded-User + - GF_AUTH_PROXY_HEADER_PROPERTY=username + - GF_AUTH_PROXY_AUTO_SIGN_UP=true + - GF_USERS_ALLOW_SIGN_UP=false + networks: + - apps + + # oauth2-proxy (Forward Auth Proxy) + grafana-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + command: + - --provider=oidc + - --oidc-issuer-url=http://atom:3000 + - --client-id=grafana-client # Create in Atom + - --client-secret=your-secret-here + - --cookie-secret=random-32-character-string-here + - --redirect-url=http://localhost:8080/oauth2/callback + - --upstream=http://grafana:3000 + - --email-domain=* + - --pass-user-headers=true + - --pass-access-token=true + - --set-xauthrequest=true + - --cookie-secure=false # Set true in production with HTTPS + ports: + - "8080:4180" + networks: + - apps + depends_on: + - atom + - grafana + +networks: + apps: + driver: bridge +``` + +**Access:** `http://localhost:8080` → oauth2-proxy → Grafana + +--- + +## Example 2: Multiple Apps with Traefik + +Use Traefik as reverse proxy + oauth2-proxy for SSO: + +```yaml +version: '3.8' + +services: + # Traefik Reverse Proxy + traefik: + image: traefik:v3.0 + command: + - --api.insecure=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + ports: + - "80:80" + - "8081:8080" # Traefik dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - proxy + + # Atom Dashboard (SSO Provider) + atom: + image: your-atom-image:latest + labels: + - "traefik.enable=true" + - "traefik.http.routers.atom.rule=Host(`atom.localhost`)" + - "traefik.http.services.atom.loadbalancer.server.port=3000" + environment: + - OAUTH_ISSUER_URL=http://atom.localhost + networks: + - proxy + + # oauth2-proxy for SSO + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + command: + - --provider=oidc + - --oidc-issuer-url=http://atom.localhost + - --client-id=proxy-client + - --client-secret=your-secret + - --cookie-secret=random-32-chars-xxxxxxxxxxxxxxxx + - --redirect-url=http://auth.localhost/oauth2/callback + - --email-domain=* + - --upstream=static://202 + - --cookie-secure=false + labels: + - "traefik.enable=true" + - "traefik.http.routers.oauth2-proxy.rule=Host(`auth.localhost`)" + - "traefik.http.services.oauth2-proxy.loadbalancer.server.port=4180" + networks: + - proxy + + # Grafana (Protected App) + grafana: + image: grafana/grafana:latest + environment: + - GF_AUTH_PROXY_ENABLED=true + - GF_AUTH_PROXY_HEADER_NAME=X-Forwarded-User + - GF_AUTH_PROXY_AUTO_SIGN_UP=true + labels: + - "traefik.enable=true" + - "traefik.http.routers.grafana.rule=Host(`grafana.localhost`)" + - "traefik.http.routers.grafana.middlewares=oauth2-auth" + - "traefik.http.middlewares.oauth2-auth.forwardauth.address=http://oauth2-proxy:4180" + - "traefik.http.middlewares.oauth2-auth.forwardauth.authResponseHeaders=X-Auth-Request-User,X-Auth-Request-Email" + - "traefik.http.services.grafana.loadbalancer.server.port=3000" + networks: + - proxy + + # Portainer (Another Protected App) + portainer: + image: portainer/portainer-ce:latest + command: --http-enabled + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + labels: + - "traefik.enable=true" + - "traefik.http.routers.portainer.rule=Host(`portainer.localhost`)" + - "traefik.http.routers.portainer.middlewares=oauth2-auth" # Same auth! + - "traefik.http.services.portainer.loadbalancer.server.port=9000" + networks: + - proxy + +volumes: + portainer_data: + +networks: + proxy: + driver: bridge +``` + +**Access:** +- Atom: `http://atom.localhost` +- Grafana: `http://grafana.localhost` (SSO via Atom) +- Portainer: `http://portainer.localhost` (SSO via Atom) + +--- + +## Example 3: Per-App Proxy (Authentik Style) + +Each app gets its own dedicated proxy container: + +```yaml +version: '3.8' + +services: + # Atom Dashboard + atom: + image: your-atom-image:latest + ports: + - "3000:3000" + environment: + - OAUTH_ISSUER_URL=http://atom:3000 + networks: + - media_stack + + # Sonarr + sonarr: + image: linuxserver/sonarr:latest + environment: + - PUID=1000 + - PGID=1000 + volumes: + - ./sonarr:/config + networks: + media_stack: + ipv4_address: 172.30.0.10 + + # Sonarr Auth Proxy + sonarr-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + environment: + - OAUTH2_PROXY_PROVIDER=oidc + - OAUTH2_PROXY_OIDC_ISSUER_URL=http://atom:3000 + - OAUTH2_PROXY_CLIENT_ID=sonarr-client + - OAUTH2_PROXY_CLIENT_SECRET=sonarr-secret + - OAUTH2_PROXY_COOKIE_SECRET=random32chars_xxxxxxxxxxxxxxxx + - OAUTH2_PROXY_REDIRECT_URL=http://sonarr.yourdomain.com/oauth2/callback + - OAUTH2_PROXY_UPSTREAMS=http://172.30.0.10:8989 + - OAUTH2_PROXY_EMAIL_DOMAINS=* + - OAUTH2_PROXY_PASS_USER_HEADERS=true + - OAUTH2_PROXY_COOKIE_SECURE=false + ports: + - "8989:4180" + networks: + media_stack: + ipv4_address: 172.30.0.11 + + # Radarr + radarr: + image: linuxserver/radarr:latest + environment: + - PUID=1000 + - PGID=1000 + volumes: + - ./radarr:/config + networks: + media_stack: + ipv4_address: 172.30.0.12 + + # Radarr Auth Proxy + radarr-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + environment: + - OAUTH2_PROXY_PROVIDER=oidc + - OAUTH2_PROXY_OIDC_ISSUER_URL=http://atom:3000 + - OAUTH2_PROXY_CLIENT_ID=radarr-client + - OAUTH2_PROXY_CLIENT_SECRET=radarr-secret + - OAUTH2_PROXY_COOKIE_SECRET=random32chars_yyyyyyyyyyyyyyyy + - OAUTH2_PROXY_REDIRECT_URL=http://radarr.yourdomain.com/oauth2/callback + - OAUTH2_PROXY_UPSTREAMS=http://172.30.0.12:7878 + - OAUTH2_PROXY_EMAIL_DOMAINS=* + - OAUTH2_PROXY_PASS_USER_HEADERS=true + - OAUTH2_PROXY_COOKIE_SECURE=false + ports: + - "7878:4180" + networks: + media_stack: + ipv4_address: 172.30.0.13 + +networks: + media_stack: + driver: bridge + ipam: + config: + - subnet: 172.30.0.0/24 +``` + +--- + +## Setup Steps + +### 1. Create OAuth2 Client in Atom + +Via Atom UI (`Settings → SSO Provider`): +1. Click "Add Application" +2. Name: `Grafana` (or app name) +3. Redirect URIs: `http://localhost:8080/oauth2/callback` +4. Allowed Scopes: `openid`, `profile`, `email` +5. Save and copy **Client ID** and **Client Secret** + +Or via API: +```bash +curl -X POST http://localhost:3000/api/oauth/clients \ + -H "Content-Type: application/json" \ + -H "Cookie: your-session-cookie" \ + -d '{ + "name": "Grafana", + "redirect_uris": ["http://localhost:8080/oauth2/callback"], + "allowed_scopes": ["openid", "profile", "email"], + "grant_types": ["authorization_code", "refresh_token"] + }' +``` + +### 2. Generate Cookie Secret + +```bash +python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())' +# or +openssl rand -base64 32 | tr -- '+/' '-_' +``` + +### 3. Configure Backend App + +Most apps support proxy authentication via headers: + +**Grafana:** +```ini +[auth.proxy] +enabled = true +header_name = X-Forwarded-User +auto_sign_up = true +``` + +**Nextcloud (config.php):** +```php +'trusted_proxies' => ['172.30.0.0/24'], +'forwarded_for_headers' => ['HTTP_X_FORWARDED_FOR'], +``` + +**Generic App:** +Configure to trust `X-Forwarded-User` or `X-Remote-User` header + +### 4. Start Services + +```bash +docker-compose up -d +``` + +### 5. Test + +1. Visit `http://localhost:8080` +2. Redirect to Atom login +3. Login with Atom credentials +4. Redirect back to app (auto-logged in) + +--- + +## Environment Variables Reference + +### oauth2-proxy Configuration + +| Variable | Description | Example | +|----------|-------------|---------| +| `OAUTH2_PROXY_PROVIDER` | OAuth provider type | `oidc` | +| `OAUTH2_PROXY_OIDC_ISSUER_URL` | Atom's OIDC issuer | `http://atom:3000` | +| `OAUTH2_PROXY_CLIENT_ID` | From Atom OAuth client | `abc123...` | +| `OAUTH2_PROXY_CLIENT_SECRET` | From Atom OAuth client | `secret123...` | +| `OAUTH2_PROXY_COOKIE_SECRET` | Random 32 chars | `xxx...` | +| `OAUTH2_PROXY_REDIRECT_URL` | Callback URL | `http://app.com/oauth2/callback` | +| `OAUTH2_PROXY_UPSTREAMS` | Backend app URL | `http://grafana:3000` | +| `OAUTH2_PROXY_EMAIL_DOMAINS` | Allowed email domains | `*` (all) | +| `OAUTH2_PROXY_PASS_USER_HEADERS` | Inject user headers | `true` | +| `OAUTH2_PROXY_COOKIE_SECURE` | HTTPS only cookies | `false` (dev), `true` (prod) | + +--- + +## Injected Headers + +oauth2-proxy injects these headers to the backend: + +- `X-Forwarded-User`: Username +- `X-Forwarded-Email`: Email +- `X-Auth-Request-User`: Username (alternative) +- `X-Auth-Request-Email`: Email (alternative) +- `Authorization`: Bearer token + +--- + +## Production Considerations + +### HTTPS/TLS + +```yaml +oauth2-proxy: + environment: + - OAUTH2_PROXY_COOKIE_SECURE=true + - OAUTH2_PROXY_REDIRECT_URL=https://app.yourdomain.com/oauth2/callback +``` + +### Session Duration + +```yaml +oauth2-proxy: + environment: + - OAUTH2_PROXY_COOKIE_EXPIRE=12h + - OAUTH2_PROXY_COOKIE_REFRESH=1h +``` + +### Restrict Users + +```yaml +oauth2-proxy: + environment: + - OAUTH2_PROXY_EMAIL_DOMAINS=yourdomain.com + # or + - OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE=/emails.txt +``` + +--- + +## Testing the Setup + +### 1. Check Atom OIDC Discovery + +```bash +curl http://localhost:3000/.well-known/openid-configuration | jq +``` + +### 2. Check oauth2-proxy Health + +```bash +curl http://localhost:4180/ping +# Should return: OK +``` + +### 3. Test Auth Flow + +```bash +# Visit protected app +curl -I http://localhost:8080/ + +# Should redirect to Atom for login +# Location: http://atom:3000/api/oauth/authorize?... +``` + +--- + +## Summary + +✅ **Atom provides:** OIDC/OAuth2 SSO +✅ **oauth2-proxy provides:** Forward authentication +✅ **Works with:** Any app that trusts proxy headers +✅ **Deployment:** One proxy per app (Authentik-style) + +This is the **production-ready, industry-standard** approach for SSO! diff --git a/package-lock.json b/package-lock.json index 5969a7d..4f6def2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,17 +16,22 @@ "better-sqlite3": "^12.5.0", "clsx": "^2.1.1", "dockerode": "^4.0.9", - "framer-motion": "^12.24.0", + "framer-motion": "^12.23.25", + "http-proxy-middleware": "^3.0.5", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.562.0", "next": "16.1.1", + "node-jose": "^2.2.0", "ping": "^1.0.0", "react": "19.2.0", "react-dom": "19.2.0", - "simple-icons": "^16.4.0", + "samlify": "^2.10.2", + "simple-icons": "^16.3.0", "sonner": "^2.0.7", - "systeminformation": "^5.29.1", + "systeminformation": "^5.28.10", "tcp-ping": "^0.1.1", - "zod": "^4.3.5" + "xml-crypto": "^6.1.2", + "zod": "^4.3.4" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -36,6 +41,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/dockerode": "^3.3.47", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25", "@types/ping": "^0.4.4", "@types/react": "^19", @@ -80,6 +86,20 @@ "dev": true, "license": "ISC" }, + "node_modules/@authenio/xml-encryption": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@authenio/xml-encryption/-/xml-encryption-2.0.2.tgz", + "integrity": "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -111,7 +131,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -681,7 +700,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -705,7 +723,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -727,7 +744,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2601,6 +2617,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2690,7 +2707,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2784,6 +2802,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2883,6 +2910,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", @@ -2905,7 +2950,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2916,7 +2960,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3038,7 +3081,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -3539,13 +3581,30 @@ "win32" ] }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4009,6 +4068,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", @@ -4085,7 +4153,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4114,7 +4181,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4176,6 +4242,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4671,6 +4743,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4745,7 +4818,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4769,6 +4843,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.266", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", @@ -5005,6 +5088,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5014,6 +5103,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5033,7 +5128,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5134,7 +5228,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5236,7 +5329,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5515,6 +5607,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5684,7 +5782,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5731,6 +5828,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5765,13 +5882,13 @@ } }, "node_modules/framer-motion": { - "version": "12.24.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.0.tgz", - "integrity": "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ==", + "version": "12.23.25", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz", + "integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==", "license": "MIT", "dependencies": { - "motion-dom": "^12.24.0", - "motion-utils": "^12.23.28", + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6251,6 +6368,20 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6265,6 +6396,23 @@ "node": ">= 14" } }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6585,7 +6733,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6650,7 +6797,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6689,7 +6835,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6712,6 +6857,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -7015,7 +7169,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7992,7 +8145,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8081,6 +8233,40 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8097,6 +8283,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8174,12 +8381,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8194,6 +8443,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -8238,6 +8493,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8319,7 +8575,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -8400,18 +8655,18 @@ "license": "MIT" }, "node_modules/motion-dom": { - "version": "12.24.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.0.tgz", - "integrity": "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig==", + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.28" + "motion-utils": "^12.23.6" } }, "node_modules/motion-utils": { - "version": "12.23.28", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.28.tgz", - "integrity": "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==", + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", "license": "MIT" }, "node_modules/ms": { @@ -8558,6 +8813,15 @@ "node": ">=10" } }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -8565,6 +8829,60 @@ "dev": true, "license": "MIT" }, + "node_modules/node-jose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.2.0.tgz", + "integrity": "sha512-XPCvJRr94SjLrSIm4pbYHKLEaOsDvJCpyFw/6V/KK/IXmyZ6SFBzAUDO9HQf4DB/nTEFcRGH87mNciOP23kFjw==", + "license": "Apache-2.0", + "dependencies": { + "base64url": "^3.0.1", + "buffer": "^6.0.3", + "es6-promise": "^4.2.8", + "lodash": "^4.17.21", + "long": "^5.2.0", + "node-forge": "^1.2.1", + "pako": "^2.0.4", + "process": "^0.11.10", + "uuid": "^9.0.0" + } + }, + "node_modules/node-jose/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/node-jose/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -8572,6 +8890,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-rsa": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz", + "integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==", + "license": "MIT", + "dependencies": { + "asn1": "^0.2.4" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8835,6 +9162,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8941,7 +9274,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -9118,7 +9450,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9148,6 +9479,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9163,6 +9495,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9175,7 +9508,17 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } }, "node_modules/prop-types": { "version": "15.8.1", @@ -9300,7 +9643,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9310,7 +9652,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9406,6 +9747,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9593,6 +9940,52 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/samlify": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/samlify/-/samlify-2.10.2.tgz", + "integrity": "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA==", + "license": "MIT", + "dependencies": { + "@authenio/xml-encryption": "^2.0.2", + "@xmldom/xmldom": "^0.8.6", + "camelcase": "^6.2.0", + "node-forge": "^1.3.0", + "node-rsa": "^1.1.1", + "pako": "^1.0.10", + "uuid": "^8.3.2", + "xml": "^1.0.1", + "xml-crypto": "^6.1.2", + "xml-escape": "^1.1.0", + "xpath": "^0.0.32" + } + }, + "node_modules/samlify/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/samlify/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/samlify/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -9887,9 +10280,9 @@ } }, "node_modules/simple-icons": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.4.0.tgz", - "integrity": "sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.3.0.tgz", + "integrity": "sha512-Bi28W15wyjt9902EnR6Mfr9gqt5ElKbKviYA80j77VkcUJtkeSGa6K4XKJWmBw1djiMPy0+it9KOk9nKMisYeQ==", "funding": [ { "type": "opencollective", @@ -10353,9 +10746,9 @@ } }, "node_modules/systeminformation": { - "version": "5.29.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.29.1.tgz", - "integrity": "sha512-bBroZc2we1tpgTBKZL7JguBdObC1OY+eXAoEBdsTp3utfcCa8+jUf+Wq/intTCJmiKJcuLEPsjCU6uMye0qvsQ==", + "version": "5.28.10", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.28.10.tgz", + "integrity": "sha512-SBNOOYYKSL1NqgG8W+5pqb6654BEvR9JnM7qvWP4bgIXuCurvyZXpBMdweVKBpVPrpzUxrgl6PbZPxrmt4RpKw==", "license": "MIT", "os": [ "darwin", @@ -10494,7 +10887,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10533,7 +10925,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -10830,7 +11221,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11282,6 +11672,41 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==", + "license": "MIT License" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -11299,6 +11724,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -11356,11 +11790,10 @@ } }, "node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", + "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e319695..bab08f4 100644 --- a/package.json +++ b/package.json @@ -33,17 +33,22 @@ "better-sqlite3": "^12.5.0", "clsx": "^2.1.1", "dockerode": "^4.0.9", - "framer-motion": "^12.24.0", + "framer-motion": "^12.23.25", + "http-proxy-middleware": "^3.0.5", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.562.0", "next": "16.1.1", + "node-jose": "^2.2.0", "ping": "^1.0.0", "react": "19.2.0", "react-dom": "19.2.0", - "simple-icons": "^16.4.0", + "samlify": "^2.10.2", + "simple-icons": "^16.3.0", "sonner": "^2.0.7", - "systeminformation": "^5.29.1", + "systeminformation": "^5.28.10", "tcp-ping": "^0.1.1", - "zod": "^4.3.5" + "xml-crypto": "^6.1.2", + "zod": "^4.3.4" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -53,6 +58,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/dockerode": "^3.3.47", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25", "@types/ping": "^0.4.4", "@types/react": "^19", diff --git a/scripts/migrate_tags.js b/scripts/migrate_tags.js new file mode 100644 index 0000000..9e91da8 --- /dev/null +++ b/scripts/migrate_tags.js @@ -0,0 +1,26 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DATA_DIR = process.env.DATA_DIR || './data'; +const DB_PATH = path.join(DATA_DIR, 'atom.db'); + +if (!fs.existsSync(DB_PATH)) { + console.error('Database not found at ' + DB_PATH); + process.exit(1); +} + +const db = new Database(DB_PATH); + +try { + console.log('Attempting to add tags column...'); + db.prepare('ALTER TABLE users ADD COLUMN tags TEXT').run(); + console.log('Successfully added tags column to users table'); +} catch (error) { + if (error.message.includes('duplicate column name')) { + console.log('Column tags already exists'); + } else { + console.error('Error adding column:', error); + process.exit(1); + } +} diff --git a/src/app/.well-known/jwks.json/route.ts b/src/app/.well-known/jwks.json/route.ts new file mode 100644 index 0000000..8d5c5ef --- /dev/null +++ b/src/app/.well-known/jwks.json/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +/** + * JWKS endpoint (root level) + * GET /.well-known/jwks.json + * + * This is an alias to /api/.well-known/jwks.json + * Some OIDC clients expect JWKS at the root level + */ +export async function GET() { + // Redirect to the actual endpoint + const response = await fetch('http://localhost:3000/api/.well-known/jwks.json'); + const data = await response.json(); + + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + }); +} diff --git a/src/app/.well-known/openid-configuration/route.ts b/src/app/.well-known/openid-configuration/route.ts new file mode 100644 index 0000000..f91e5dd --- /dev/null +++ b/src/app/.well-known/openid-configuration/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +/** + * OIDC Discovery endpoint (root level) + * GET /.well-known/openid-configuration + * + * This is an alias to /api/.well-known/openid-configuration + * Some OIDC clients expect discovery at the root level + */ +export async function GET() { + // Redirect to the actual endpoint + const response = await fetch('http://localhost:3000/api/.well-known/openid-configuration'); + const data = await response.json(); + + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=3600', + }, + }); +} diff --git a/src/app/api/.well-known/jwks.json/route.ts b/src/app/api/.well-known/jwks.json/route.ts new file mode 100644 index 0000000..6596ef4 --- /dev/null +++ b/src/app/api/.well-known/jwks.json/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { getJWKS } from '../../../../lib/oidc/jwt'; + +/** + * JWKS (JSON Web Key Set) Endpoint + * GET /.well-known/jwks.json + * + * Returns public keys for JWT verification + */ +export async function GET() { + try { + const jwks = getJWKS(); + + return NextResponse.json(jwks, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=86400', // Cache for 24 hours + }, + }); + } catch (error) { + console.error('JWKS error:', error); + return NextResponse.json( + { error: 'server_error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/.well-known/openid-configuration/route.ts b/src/app/api/.well-known/openid-configuration/route.ts new file mode 100644 index 0000000..c452398 --- /dev/null +++ b/src/app/api/.well-known/openid-configuration/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getOIDCConfiguration } from '../../../../lib/oidc/discovery'; + +/** + * OIDC Discovery Endpoint + * GET /.well-known/openid-configuration + * + * Returns OpenID Connect Discovery metadata + */ +export async function GET(request: NextRequest) { + try { + // Dynamically determine base URL from request headers + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + const baseUrl = `${protocol}://${host}`; + + const config = getOIDCConfiguration(baseUrl); + + return NextResponse.json(config, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + }, + }); + } catch (error) { + console.error('OIDC Discovery error:', error); + return NextResponse.json( + { error: 'server_error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts new file mode 100644 index 0000000..ae80024 --- /dev/null +++ b/src/app/api/auth/[provider]/callback/route.ts @@ -0,0 +1,196 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthProviderBySlug, linkFederatedIdentity, getFederatedIdentity } from '@/lib/auth-providers'; +import { createUser, getUserByUsername, createSession, getUserById } from '@/lib/db'; // getUserById, getUserByEmail if exists? +import { fetchOIDCConfiguration } from '@/lib/oidc/client-discovery'; +import jose from 'node-jose'; +import { cookies } from 'next/headers'; + +// We need a way to find user by email from db.ts +// Assuming query `SELECT * FROM users WHERE email = ?` exists or I add it. +import db from '@/lib/db'; + +function getUserByEmail(email: string) { + const stmt = db.prepare('SELECT * FROM users WHERE email = ?'); + return stmt.get(email) as any; +} + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider: slug } = await params; + + // 1. Validate State + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + if (error) { + return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, request.url)); + } + + if (!code || !state) { + return NextResponse.redirect(new URL('/login?error=Missing+code+or+state', request.url)); + } + + const cookieStore = await cookies(); + const stateCookie = cookieStore.get('atom_oauth_state'); + + if (!stateCookie) { + return NextResponse.redirect(new URL('/login?error=State+cookie+missing', request.url)); + } + + let savedState: any; + try { + savedState = JSON.parse(stateCookie.value); + } catch { + return NextResponse.redirect(new URL('/login?error=Invalid+state+cookie', request.url)); + } + + if (savedState.state !== state || savedState.provider !== slug) { + return NextResponse.redirect(new URL('/login?error=State+mismatch', request.url)); + } + + // Clear state cookie + cookieStore.delete('atom_oauth_state'); + + // 2. Get Provider Config + const provider = getAuthProviderBySlug(slug); + if (!provider || !provider.enabled) { + return NextResponse.redirect(new URL('/login?error=Provider+disabled', request.url)); + } + + try { + // 3. Discover Endpoints if needed + let tokenEndpoint = provider.token_endpoint; + let jwksUri = provider.jwks_uri; + + if (!tokenEndpoint || !jwksUri) { + const config = await fetchOIDCConfiguration(provider.issuer); + tokenEndpoint = config.token_endpoint; + jwksUri = config.jwks_uri; + } + + // 4. Exchange Code + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + const redirectUri = `${protocol}://${host}/api/auth/${slug}/callback`; + + const tokenParams = new URLSearchParams(); + tokenParams.append('grant_type', 'authorization_code'); + tokenParams.append('code', code); + tokenParams.append('redirect_uri', redirectUri); + tokenParams.append('client_id', provider.client_id); + tokenParams.append('client_secret', provider.client_secret); + + const tokenRes = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams + }); + + if (!tokenRes.ok) { + const errBody = await tokenRes.text(); + console.error('Token exchange failed:', errBody); + throw new Error('Failed to exchange token'); + } + + const tokens = await tokenRes.json(); + const idToken = tokens.id_token; + + if (!idToken) throw new Error('No id_token returned'); + + // 5. Verify ID Token + // Fetch JWKS + const jwksRes = await fetch(jwksUri, { next: { revalidate: 3600 } }); + const jwks = await jwksRes.json(); + + const keystore = await jose.JWK.asKeyStore(jwks); + + // Validate signature + const result = await jose.JWS.createVerify(keystore).verify(idToken); + const payload = JSON.parse(result.payload.toString()); + + // Validate claims + // Issuer check: Payload issuer must match (or start with) provider issuer + // Some providers add trailing slashes, so simple verify + if (!payload.iss.startsWith(provider.issuer) && !provider.issuer.startsWith(payload.iss)) { + // throw new Error(`Issuer mismatch: ${payload.iss} vs ${provider.issuer}`); + // Warn only for now as distinct trailing slashes are common + console.warn(`Issuer mismatch warning: ${payload.iss} vs ${provider.issuer}`); + } + + // Aud check + const aud = payload.aud; + if (Array.isArray(aud)) { + if (!aud.includes(provider.client_id)) throw new Error('Audience mismatch'); + } else if (aud !== provider.client_id) { + throw new Error(`Audience mismatch: ${aud} vs ${provider.client_id}`); + } + + // 6. User Linking / Creation + const subject = payload.sub; + const email = payload.email; // Requires 'email' scope + + let federatedIdentity = getFederatedIdentity(slug, subject); + let userId: number; + + if (federatedIdentity) { + // User exists, log them in + userId = federatedIdentity.user_id; + } else { + // New federated user + // Check if email exists + let existingUser = email ? getUserByEmail(email) : null; + + if (existingUser) { + // Link to existing user + userId = existingUser.id; + } else { + // Create new user + const username = payload.preferred_username || payload.name || email?.split('@')[0] || `user_${subject.substring(0, 8)}`; + // Handle username collision logic... simplified for now + // We generate a dummy password hash + const dummyHash = '$2a$10$federated_dummy_hash_auth_' + crypto.randomUUID(); + + try { + const newUser = createUser(username, dummyHash, email); + userId = newUser.id; + } catch (e) { + // If username taken, try appending random suffix + const uniqueName = `${username}_${crypto.randomUUID().substring(0, 4)}`; + const newUser = createUser(uniqueName, dummyHash, email); + userId = newUser.id; + } + } + + // Create Link + linkFederatedIdentity(userId, slug, subject, email); + } + + // 7. Create Session + const session = createSession(userId); + + // Set session Cookie + const response = NextResponse.redirect(new URL('/', request.url)); + const isSecure = process.env.COOKIE_SECURE === 'true'; + + response.cookies.set('atom_session', session.id, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + // db.ts says 7 days + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + path: '/', + }); + + return response; + + } catch (error) { + console.error('Callback error:', error); + return NextResponse.redirect(new URL('/login?error=Authentication+failed', request.url)); + } +} diff --git a/src/app/api/auth/[provider]/login/route.ts b/src/app/api/auth/[provider]/login/route.ts new file mode 100644 index 0000000..8c9f205 --- /dev/null +++ b/src/app/api/auth/[provider]/login/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthProviderBySlug } from '@/lib/auth-providers'; +import { fetchOIDCConfiguration } from '@/lib/oidc/client-discovery'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> } +) { + const { provider: slug } = await params; + const provider = getAuthProviderBySlug(slug); + + if (!provider || !provider.enabled) { + return NextResponse.json({ error: 'Provider not found or disabled' }, { status: 404 }); + } + + try { + let authEndpoint = provider.authorization_endpoint; + + // Auto-discover if missing + if (!authEndpoint) { + const config = await fetchOIDCConfiguration(provider.issuer); + authEndpoint = config.authorization_endpoint; + } + + // Construct Redirect URI (Callback) + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + const redirectUri = `${protocol}://${host}/api/auth/${slug}/callback`; + + // Generate State & Nonce + const state = crypto.randomUUID(); + const nonce = crypto.randomUUID(); + + // Construct Auth URL + const url = new URL(authEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', provider.client_id); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', state); + url.searchParams.set('nonce', nonce); + + // Create response with redirect + const response = NextResponse.redirect(url.toString()); + + // Store state in cookie for validation in callback + // Expires in 10 minutes + response.cookies.set('atom_oauth_state', JSON.stringify({ state, nonce, provider: slug }), { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'lax', + maxAge: 600, + path: '/api/auth' // Limit scope to auth routes + }); + + return response; + + } catch (error) { + console.error(`Login init failed for ${slug}:`, error); + return NextResponse.json({ error: 'Failed to initiate login' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts new file mode 100644 index 0000000..711956f --- /dev/null +++ b/src/app/api/auth/providers/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + createAuthProvider, + listAuthProviders, + listEnabledAuthProviders, + AuthProvider +} from '@/lib/auth-providers'; +import { getOIDCConfiguration } from '@/lib/oidc/discovery'; // We might want a real discovery fetcher later + +export const dynamic = 'force-dynamic'; + +// Public endpoint to list enabled providers (for login page) +// Or authenticated endpoint to list all (for settings)? +// Let's allow public listing of ENABLED providers. +export async function GET(request: NextRequest) { + try { + const user = await getCurrentUser(); + const searchParams = request.nextUrl.searchParams; + const all = searchParams.get('all') === 'true'; + + if (all) { + // Only authenticated users can see ALL providers (including disabled) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const providers = listAuthProviders(); + // Scrub secrets + const safeProviders = providers.map(p => ({ ...p, client_secret: undefined })); + return NextResponse.json(safeProviders); + } else { + // Public list for Login Page + const providers = listEnabledAuthProviders(); + // Ideally we only need name and slug and auth url? + // We need slug to construct /api/auth/[slug]/login + const publicProviders = providers.map(p => ({ + name: p.name, + slug: p.slug + })); + return NextResponse.json(publicProviders); + } + } catch (error) { + console.error('Failed to list providers:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +// Protected endpoint to create a provider +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + + // Basic validation + if (!body.name || !body.slug || !body.issuer || !body.client_id || !body.client_secret) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + // Attempt to auto-discover endpoints if not provided + // For now, we'll just respect what's passed or default to none (which means we need to discover later or fail) + // A better UX would be to fetch discovery URL here and populate. + + const newProvider = createAuthProvider({ + name: body.name, + slug: body.slug, + issuer: body.issuer, + client_id: body.client_id, + client_secret: body.client_secret, + authorization_endpoint: body.authorization_endpoint, + token_endpoint: body.token_endpoint, + userinfo_endpoint: body.userinfo_endpoint, + jwks_uri: body.jwks_uri, + enabled: body.enabled !== false // Default to true + }); + + return NextResponse.json({ + success: true, + provider: { ...newProvider, client_secret: undefined } + }); + + } catch (error) { + console.error('Failed to create provider:', error); + // SQLite unique constraint check could be done here + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +// Protected endpoint to delete a provider +export async function DELETE(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { slug } = await request.json(); + + if (!slug) { + return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + } + + const { deleteAuthProvider } = await import('@/lib/auth-providers'); + const success = deleteAuthProvider(slug); + + if (success) { + return NextResponse.json({ success: true }); + } else { + return NextResponse.json({ error: 'Provider not found' }, { status: 404 }); + } + + } catch (error) { + console.error('Failed to delete provider:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 88082e9..893cddf 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -5,7 +5,7 @@ import { registerSchema } from '@/lib/validation'; export async function POST(request: Request) { try { const body = await request.json(); - + // Validate request body const validationResult = registerSchema.safeParse(body); if (!validationResult.success) { @@ -15,8 +15,8 @@ export async function POST(request: Request) { ); } - const { username, password } = validationResult.data; - const result = await register(username, password); + const { username, password, email } = validationResult.data; + const result = await register(username, password, email); if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }); diff --git a/src/app/api/oauth/authorize/route.ts b/src/app/api/oauth/authorize/route.ts new file mode 100644 index 0000000..87f9d63 --- /dev/null +++ b/src/app/api/oauth/authorize/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { validateAuthorizeRequest, generateAuthorizationCode, type AuthorizeParams } from '@/lib/oauth/provider'; +import { getUserConsent } from '@/lib/db-oauth'; +import { getConfig } from '@/lib/config'; +import { redirect } from 'next/navigation'; + +/** + * OAuth2 Authorization Endpoint + * GET /api/oauth/authorize + * + * Handles authorization requests from OAuth clients. + * Validates the request and either shows consent screen or auto-approves if consent exists. + */ +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + + // Parse authorization parameters + const params: AuthorizeParams = { + response_type: searchParams.get('response_type') || '', + client_id: searchParams.get('client_id') || '', + redirect_uri: searchParams.get('redirect_uri') || '', + scope: searchParams.get('scope') || undefined, + state: searchParams.get('state') || undefined, + code_challenge: searchParams.get('code_challenge') || undefined, + code_challenge_method: (searchParams.get('code_challenge_method') as 'S256' | 'plain') || undefined, + nonce: searchParams.get('nonce') || undefined, // OIDC nonce for replay protection + }; + + // Validate authorization request + const validation = validateAuthorizeRequest(params); + if (!validation.success) { + // Return error to redirect URI if possible + if (params.redirect_uri) { + const errorUrl = new URL(params.redirect_uri); + errorUrl.searchParams.set('error', validation.error!); + if (validation.error_description) { + errorUrl.searchParams.set('error_description', validation.error_description); + } + if (params.state) { + errorUrl.searchParams.set('state', params.state); + } + return NextResponse.redirect(errorUrl.toString()); + } + + // Otherwise return JSON error + return NextResponse.json( + { + error: validation.error, + error_description: validation.error_description, + }, + { status: 400 } + ); + } + + // Dynamically determine base URL from request headers to avoid 0.0.0.0 redirects + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + const baseUrl = `${protocol}://${host}`; + + // Check if user is authenticated + const user = await getCurrentUser(); + if (!user) { + // Redirect to login with return URL + const loginUrl = new URL('/login', baseUrl); + // Construct return URL using the dynamic base URL to avoid 0.0.0.0 + const returnTo = new URL(request.nextUrl.pathname + request.nextUrl.search, baseUrl); + loginUrl.searchParams.set('returnTo', returnTo.toString()); + return NextResponse.redirect(loginUrl.toString()); + } + + // Access Control Check + const config = await getConfig(); + if (config) { + const service = config.services.find(s => s.id === params.client_id); + if (service && service.tags && service.tags.length > 0) { + const userTags = user.tags || []; + // Check if user has "all" tag or matches any service tag + const hasAccess = userTags.includes('all') || service.tags.some(t => userTags.includes(t)); + + if (!hasAccess) { + // Deny access + if (params.redirect_uri) { + const errorUrl = new URL(params.redirect_uri); + errorUrl.searchParams.set('error', 'access_denied'); + errorUrl.searchParams.set('error_description', 'User does not have the required tags to access this application'); + if (params.state) { + errorUrl.searchParams.set('state', params.state); + } + return NextResponse.redirect(errorUrl.toString()); + } else { + return NextResponse.json( + { error: 'access_denied', error_description: 'User does not have the required tags to access this application' }, + { status: 403 } + ); + } + } + } + } + + // Check if user has already consented to this client + const existingConsent = getUserConsent(user.id, params.client_id); + + // If consent exists and covers all requested scopes, auto-approve + if (existingConsent) { + const requestedScopes = validation.scopes!; + const hasAllScopes = requestedScopes.every(scope => + existingConsent.scopes.includes(scope) + ); + + if (hasAllScopes) { + // Auto-approve: generate authorization code and redirect + const code = generateAuthorizationCode( + params.client_id, + user.id, + params.redirect_uri, + requestedScopes, + params.code_challenge, + params.code_challenge_method, + params.nonce + ); + + const redirectUrl = new URL(params.redirect_uri); + redirectUrl.searchParams.set('code', code); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + + return NextResponse.redirect(redirectUrl.toString()); + } + } + + // Show consent screen + const consentUrl = new URL('/oauth/consent', baseUrl); + consentUrl.searchParams.set('client_id', params.client_id); + consentUrl.searchParams.set('redirect_uri', params.redirect_uri); + consentUrl.searchParams.set('scope', params.scope || 'openid'); + if (params.state) { + consentUrl.searchParams.set('state', params.state); + } + if (params.code_challenge) { + consentUrl.searchParams.set('code_challenge', params.code_challenge); + } + if (params.code_challenge_method) { + consentUrl.searchParams.set('code_challenge_method', params.code_challenge_method); + } + + return NextResponse.redirect(consentUrl.toString()); +} + +/** + * OAuth2 Authorization Endpoint - Consent Submission + * POST /api/oauth/authorize + * + * Handles user consent (approve/deny) for authorization requests. + */ +export async function POST(request: NextRequest) { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method, + approved + } = body; + + // If user denied, redirect with error + if (!approved) { + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('error', 'access_denied'); + redirectUrl.searchParams.set('error_description', 'User denied the authorization request'); + if (state) { + redirectUrl.searchParams.set('state', state); + } + return NextResponse.json({ redirect_uri: redirectUrl.toString() }); + } + + // User approved: generate authorization code + const scopes = scope.split(' ').filter((s: string) => s.length > 0); + const code = generateAuthorizationCode( + client_id, + user.id, + redirect_uri, + scopes, + code_challenge, + code_challenge_method, + body.nonce + ); + + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('code', code); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + return NextResponse.json({ redirect_uri: redirectUrl.toString() }); +} diff --git a/src/app/api/oauth/clients/route.ts b/src/app/api/oauth/clients/route.ts new file mode 100644 index 0000000..de10739 --- /dev/null +++ b/src/app/api/oauth/clients/route.ts @@ -0,0 +1,342 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +import { getCurrentUser } from '@/lib/auth'; +import { + createOAuthClient, + listOAuthClients, + deleteOAuthClient, + updateOAuthClient, + getOAuthClientByClientId +} from '@/lib/db-oauth'; +import { SUPPORTED_SCOPES } from '@/lib/oauth/types'; +import { getConfig, saveConfig } from '@/lib/config'; +import { AppConfig, Service } from '@/types'; + +/** + * OAuth2 Client Management API + * GET /api/oauth/clients - List all OAuth clients + * POST /api/oauth/clients - Create a new OAuth client + * PATCH /api/oauth/clients/:id - Update an OAuth client + * DELETE /api/oauth/clients/:id - Delete an OAuth client + * + * Requires dashboard authentication. + */ + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const clientId = searchParams.get('client_id'); + + // Allow fetching a specific client by client_id without authentication + // This is needed for the consent screen to display client information + if (clientId) { + try { + const client = getOAuthClientByClientId(clientId); + if (!client) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }); + } + + // Return only public information (no secret) + return NextResponse.json({ + client: { + id: client.id, + client_id: client.client_id, + name: client.name, + description: client.description, + redirect_uris: client.redirect_uris, + allowed_scopes: client.allowed_scopes, + }, + }); + } catch (error) { + console.error('Error fetching OAuth client:', error); + return NextResponse.json({ error: 'server_error' }, { status: 500 }); + } + } + + // For listing all clients, require authentication + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + try { + const clients = listOAuthClients(); + + // Sync existing clients to dashboard config + try { + const config = await getConfig(); + let configChanged = false; + console.log('Checking for OAuth clients to sync with dashboard...'); + + if (!config.services) { + config.services = []; + } + + for (const client of clients) { + const existingService = config.services.find(s => s.id === client.client_id); + + if (!existingService && client.redirect_uris && client.redirect_uris.length > 0) { + try { + const serviceUrl = new URL(client.redirect_uris[0]).origin; + const newService: Service = { + id: client.client_id, + name: client.name, + url: serviceUrl, + description: client.description || '', + icon: 'lock', + category: 'Applications', + createdAt: Date.now(), // We don't have created_at in basic client list, use now + updatedAt: Date.now() + }; + + config.services.push(newService); + configChanged = true; + } catch (e) { + // Skip invalid URLs + } + } + } + + if (configChanged) { + await saveConfig(config); + } + } catch (syncError) { + console.error('Failed to sync clients to dashboard:', syncError); + // Non-blocking error + } + + // Don't return client secrets in list view + const safeClients = clients.map(client => { + const { client_secret, ...rest } = client; + return rest; + }); + + return NextResponse.json({ clients: safeClients }); + } catch (error) { + console.error('Error listing OAuth clients:', error); + return NextResponse.json( + { error: 'server_error', message: 'Failed to list clients' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + // Verify user is authenticated + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { name, description, redirect_uris, allowed_scopes, grant_types, is_confidential } = body; + + // Validate required fields + if (!name || !redirect_uris || redirect_uris.length === 0) { + return NextResponse.json( + { error: 'invalid_request', message: 'Name and at least one redirect URI are required' }, + { status: 400 } + ); + } + + // Validate redirect URIs + for (const uri of redirect_uris) { + try { + new URL(uri); + } catch { + return NextResponse.json( + { error: 'invalid_request', message: `Invalid redirect URI: ${uri}` }, + { status: 400 } + ); + } + } + + // Use default scopes if not provided + const scopes = allowed_scopes && allowed_scopes.length > 0 + ? allowed_scopes + : Array.from(SUPPORTED_SCOPES); + + // Use default grant types if not provided + const grants = grant_types && grant_types.length > 0 + ? grant_types + : ['authorization_code', 'refresh_token']; + + // Create client + const client = createOAuthClient( + name, + description, + redirect_uris, + scopes, + grants, + is_confidential !== false // Default to confidential + ); + + // Add to dashboard config + try { + const config = await getConfig(); + const serviceUrl = new URL(redirect_uris[0]).origin; + + const newService: Service = { + id: client.client_id, // Link service ID to client ID + name: client.name, + url: serviceUrl, + description: client.description, + icon: 'lock', // Default icon for OAuth apps + category: 'Applications', + createdAt: Date.now(), + updatedAt: Date.now() + }; + + // Ensure services array exists + if (!config.services) config.services = []; + + config.services.push(newService); + await saveConfig(config); + } catch (configError) { + console.error('Failed to add client to dashboard config:', configError); + // Don't fail the request if config update fails, just log it + } + + return NextResponse.json({ client }, { status: 201 }); + } catch (error) { + console.error('Error creating OAuth client:', error); + return NextResponse.json( + { error: 'server_error', message: 'Failed to create client' }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest) { + // Verify user is authenticated + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { client_id, name, description, redirect_uris, allowed_scopes, grant_types } = body; + + if (!client_id) { + return NextResponse.json( + { error: 'invalid_request', message: 'client_id is required' }, + { status: 400 } + ); + } + + // Validate redirect URIs if provided + if (redirect_uris) { + for (const uri of redirect_uris) { + try { + new URL(uri); + } catch { + return NextResponse.json( + { error: 'invalid_request', message: `Invalid redirect URI: ${uri}` }, + { status: 400 } + ); + } + } + } + + const updated = updateOAuthClient(client_id, { + name, + description, + redirect_uris, + allowed_scopes, + grant_types, + }); + + if (!updated) { + return NextResponse.json( + { error: 'not_found', message: 'Client not found' }, + { status: 404 } + ); + } + + // Update dashboard config if exists + try { + const config = await getConfig(); + const serviceIndex = config.services?.findIndex(s => s.id === client_id); + + if (serviceIndex !== undefined && serviceIndex !== -1 && config.services) { + const service = config.services[serviceIndex]; + + // Update fields if provided + if (name) service.name = name; + if (description) service.description = description; + if (redirect_uris && redirect_uris.length > 0) { + service.url = new URL(redirect_uris[0]).origin; + } + service.updatedAt = Date.now(); + + config.services[serviceIndex] = service; + await saveConfig(config); + } + } catch (configError) { + console.error('Failed to update dashboard config for client:', configError); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating OAuth client:', error); + return NextResponse.json( + { error: 'server_error', message: 'Failed to update client' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + // Verify user is authenticated + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const client_id = searchParams.get('client_id'); + + if (!client_id) { + return NextResponse.json( + { error: 'invalid_request', message: 'client_id is required' }, + { status: 400 } + ); + } + + const deleted = deleteOAuthClient(client_id); + + if (!deleted) { + return NextResponse.json( + { error: 'not_found', message: 'Client not found' }, + { status: 404 } + ); + } + + // Remove from dashboard config + try { + const config = await getConfig(); + if (config.services) { + const initialLength = config.services.length; + config.services = config.services.filter(s => s.id !== client_id); + + if (config.services.length !== initialLength) { + await saveConfig(config); + } + } + } catch (configError) { + console.error('Failed to remove client from dashboard config:', configError); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting OAuth client:', error); + return NextResponse.json( + { error: 'server_error', message: 'Failed to delete client' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/introspect/route.ts b/src/app/api/oauth/introspect/route.ts new file mode 100644 index 0000000..e8c5166 --- /dev/null +++ b/src/app/api/oauth/introspect/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { introspectToken } from '@/lib/oauth/provider'; +import { validateClientCredentials } from '@/lib/oauth/provider'; + +/** + * OAuth2 Token Introspection Endpoint + * POST /api/oauth/introspect + * + * Validates a token and returns metadata about it. + * Requires client authentication. + */ +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const client_id = formData.get('client_id') as string; + const client_secret = formData.get('client_secret') as string; + const token = formData.get('token') as string; + + // Validate client credentials + const client = validateClientCredentials(client_id, client_secret); + if (!client) { + return NextResponse.json( + { error: 'invalid_client' }, + { status: 401 } + ); + } + + if (!token) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing token parameter' }, + { status: 400 } + ); + } + + // Introspect token + const result = introspectToken(token); + + return NextResponse.json(result); + } catch (error) { + console.error('Introspection endpoint error:', error); + return NextResponse.json( + { error: 'server_error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/revoke/route.ts b/src/app/api/oauth/revoke/route.ts new file mode 100644 index 0000000..b44a00d --- /dev/null +++ b/src/app/api/oauth/revoke/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { revokeToken } from '@/lib/oauth/provider'; +import { validateClientCredentials } from '@/lib/oauth/provider'; + +/** + * OAuth2 Token Revocation Endpoint + * POST /api/oauth/revoke + * + * Revokes an access or refresh token. + * Requires client authentication. + */ +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const client_id = formData.get('client_id') as string; + const client_secret = formData.get('client_secret') as string; + const token = formData.get('token') as string; + const token_type_hint = formData.get('token_type_hint') as 'access_token' | 'refresh_token' | null; + + // Validate client credentials + const client = validateClientCredentials(client_id, client_secret); + if (!client) { + return NextResponse.json( + { error: 'invalid_client' }, + { status: 401 } + ); + } + + if (!token) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing token parameter' }, + { status: 400 } + ); + } + + // Revoke token + revokeToken(token, token_type_hint || undefined); + + // Per RFC 7009, revocation endpoint returns 200 even if token doesn't exist + return NextResponse.json({}); + } catch (error) { + console.error('Revocation endpoint error:', error); + return NextResponse.json( + { error: 'server_error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/token/route.ts b/src/app/api/oauth/token/route.ts new file mode 100644 index 0000000..1727798 --- /dev/null +++ b/src/app/api/oauth/token/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { handleTokenRequest, type TokenParams } from '@/lib/oauth/provider'; + +/** + * OAuth2 Token Endpoint + * POST /api/oauth/token + * + * Handles token requests for all grant types: + * - authorization_code + * - client_credentials + * - refresh_token + */ +export async function POST(request: NextRequest) { + try { + // Parse form data (OAuth2 tokens use application/x-www-form-urlencoded) + const formData = await request.formData(); + + // Dynamically determine issuer URL from request headers + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + const issuer = `${protocol}://${host}`; + + // Check for Basic Auth header + let clientId = formData.get('client_id') as string || ''; + let clientSecret = formData.get('client_secret') as string || ''; + + console.log('Token Endpoint: Initial body credentials:', { clientId: clientId ? '***' : 'missing', hasSecret: !!clientSecret }); + + const authHeader = request.headers.get('authorization'); + if (!clientId && authHeader && authHeader.startsWith('Basic ')) { + try { + const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':'); + clientId = credentials[0]; + clientSecret = credentials[1]; + console.log('Token Endpoint: Extracted credentials from Basic Auth header'); + } catch (e) { + console.error('Failed to parse Basic Auth header:', e); + } + } + + // console.log('Token Endpoint: Final credentials:', { clientId, hasSecret: !!clientSecret }); + + const params: TokenParams = { + grant_type: formData.get('grant_type') as string || '', + code: formData.get('code') as string || undefined, + redirect_uri: formData.get('redirect_uri') as string || undefined, + client_id: clientId, + client_secret: clientSecret, + code_verifier: formData.get('code_verifier') as string || undefined, + refresh_token: formData.get('refresh_token') as string || undefined, + scope: formData.get('scope') as string || undefined, + issuer, // Pass dynamic issuer + }; + + // Handle token request + const result = handleTokenRequest(params); + + if (!result.success) { + return NextResponse.json( + { + error: result.error, + error_description: result.error_description, + }, + { status: 400 } + ); + } + + // Return successful token response + return NextResponse.json({ + access_token: result.access_token, + token_type: result.token_type, + expires_in: result.expires_in, + refresh_token: result.refresh_token, + scope: result.scope, + id_token: result.id_token, // Include ID token for OIDC + }); + } catch (error) { + console.error('Token endpoint error:', error); + return NextResponse.json( + { + error: 'server_error', + error_description: 'An internal server error occurred', + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/userinfo/route.ts b/src/app/api/oauth/userinfo/route.ts new file mode 100644 index 0000000..a0bc3cd --- /dev/null +++ b/src/app/api/oauth/userinfo/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAccessToken } from '@/lib/db-oauth'; +import { getUserById } from '@/lib/db'; + +/** + * OAuth2 UserInfo Endpoint + * GET /api/oauth/userinfo + * + * Returns user profile information for the authenticated user. + * Requires valid access token in Authorization header. + */ +export async function GET(request: NextRequest) { + // Extract access token from Authorization header + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'invalid_token', error_description: 'Missing or invalid authorization header' }, + { status: 401 } + ); + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + // Validate access token + let accessToken = getAccessToken(token); + + // If not found in DB, try verifying as JWT (for OIDC stateless tokens) + if (!accessToken) { + try { + const { verifyJWT } = require('@/lib/oidc/jwt'); + const payload = verifyJWT(token); + + // Reconstruct access token object from JWT payload + if (payload && payload.sub) { + const scopes = payload.scope ? payload.scope.split(' ') : []; + // Check for client_credentials case where sub might be client_id + const isClientCredentials = payload.sub === payload.client_id; + + accessToken = { + id: 0, // Placeholder + token: token, + client_id: payload.client_id as string || payload.aud as string, + user_id: isClientCredentials ? null : parseInt(payload.sub), + scopes: scopes, + expires_at: new Date(payload.exp * 1000).toISOString(), + created_at: new Date(payload.iat * 1000).toISOString(), + revoked: false + }; + } + } catch (e) { + // JWT verification failed + console.error('UserInfo: JWT verification failed:', e); + } + } + + if (!accessToken) { + return NextResponse.json( + { error: 'invalid_token', error_description: 'Invalid or expired access token' }, + { status: 401 } + ); + } + + // Client credentials grant doesn't have a user + if (!accessToken.user_id) { + return NextResponse.json( + { error: 'invalid_token', error_description: 'Token is not associated with a user' }, + { status: 400 } + ); + } + + // Get user information + const user = getUserById(accessToken.user_id); + if (!user) { + return NextResponse.json( + { error: 'invalid_token', error_description: 'User not found' }, + { status: 404 } + ); + } + + // Build response based on granted scopes + const scopes = accessToken.scopes; + const userInfo: Record = { + sub: user.id.toString(), + }; + + // Add profile information if profile or username scope is granted + if (scopes.includes('profile') || scopes.includes('username')) { + userInfo.preferred_username = user.username; + userInfo.name = user.username; + } + + // Add email if email scope is granted + if (scopes.includes('email') && user.email) { + userInfo.email = user.email; + userInfo.email_verified = true; + } + + return NextResponse.json(userInfo); +} diff --git a/src/app/api/proxy/[slug]/[...path]/route.ts b/src/app/api/proxy/[slug]/[...path]/route.ts new file mode 100644 index 0000000..c6c82a8 --- /dev/null +++ b/src/app/api/proxy/[slug]/[...path]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from 'next/server'; +import { proxyRequest } from '../../../../../lib/proxy/auth-proxy'; + +/** + * Authentication Proxy Endpoint + * ALL /api/proxy/[slug]/[...path] + * + * Proxies requests to protected backend applications with authentication + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ slug: string; path: string[] }> } +) { + const { slug, path } = await context.params; + const fullPath = '/' + (path || []).join('/'); + + return proxyRequest(request, slug, fullPath); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ slug: string; path: string[] }> } +) { + const { slug, path } = await context.params; + const fullPath = '/' + (path || []).join('/'); + + return proxyRequest(request, slug, fullPath); +} + +export async function PUT( + request: NextRequest, + context: { params: Promise<{ slug: string; path: string[] }> } +) { + const { slug, path } = await context.params; + const fullPath = '/' + (path || []).join('/'); + + return proxyRequest(request, slug, fullPath); +} + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ slug: string; path: string[] }> } +) { + const { slug, path } = await context.params; + const fullPath = '/' + (path || []).join('/'); + + return proxyRequest(request, slug, fullPath); +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ slug: string; path: string[] }> } +) { + const { slug, path } = await context.params; + const fullPath = '/' + (path || []).join('/'); + + return proxyRequest(request, slug, fullPath); +} diff --git a/src/app/api/proxy/apps/route.ts b/src/app/api/proxy/apps/route.ts new file mode 100644 index 0000000..8cfc61d --- /dev/null +++ b/src/app/api/proxy/apps/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { + createProtectedApplication, + listProtectedApplications, + getProtectedApplicationBySlug, + updateProtectedApplication, + deleteProtectedApplication, +} from '@/lib/proxy/db-proxy'; + +/** + * GET /api/proxy/apps - List all protected applications + */ +export async function GET(request: NextRequest) { + // Require authentication + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const apps = listProtectedApplications(); + + return NextResponse.json({ applications: apps }); +} + +/** + * POST /api/proxy/apps - Create new protected application + */ +export async function POST(request: NextRequest) { + // Require authentication + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { name, slug, backend_url, require_auth, allowed_users, inject_headers, strip_auth_header } = body; + + // Validation + if (!name || !slug || !backend_url) { + return NextResponse.json( + { error: 'Missing required fields: name, slug, backend_url' }, + { status: 400 } + ); + } + + // Validate slug format (alphanumeric and hyphens only) + if (!/^[a-z0-9-]+$/.test(slug)) { + return NextResponse.json( + { error: 'Invalid slug format. Use lowercase letters, numbers, and hyphens only.' }, + { status: 400 } + ); + } + + // Check if slug already exists + const existing = getProtectedApplicationBySlug(slug); + if (existing) { + return NextResponse.json( + { error: 'Application with this slug already exists' }, + { status: 409 } + ); + } + + // Create application + const app = createProtectedApplication( + name, + slug, + backend_url, + require_auth ?? true, + allowed_users || null, + inject_headers ?? true, + strip_auth_header ?? true + ); + + return NextResponse.json({ application: app }, { status: 201 }); + } catch (error) { + console.error('Failed to create protected application:', error); + return NextResponse.json( + { error: 'Failed to create application' }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/proxy/apps?slug=... - Update protected application + */ +export async function PATCH(request: NextRequest) { + // Require authentication + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const slug = url.searchParams.get('slug'); + + if (!slug) { + return NextResponse.json( + { error: 'Missing slug parameter' }, + { status: 400 } + ); + } + + try { + const body = await request.json(); + + const updated = updateProtectedApplication(slug, body); + + if (!updated) { + return NextResponse.json( + { error: 'Application not found' }, + { status: 404 } + ); + } + + const app = getProtectedApplicationBySlug(slug); + return NextResponse.json({ application: app }); + } catch (error) { + console.error('Failed to update protected application:', error); + return NextResponse.json( + { error: 'Failed to update application' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/proxy/apps?slug=... - Delete protected application + */ +export async function DELETE(request: NextRequest) { + // Require authentication + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const url = new URL(request.url); + const slug = url.searchParams.get('slug'); + + if (!slug) { + return NextResponse.json( + { error: 'Missing slug parameter' }, + { status: 400 } + ); + } + + const deleted = deleteProtectedApplication(slug); + + if (!deleted) { + return NextResponse.json( + { error: 'Application not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ message: 'Application deleted successfully' }); +} diff --git a/src/app/api/saml/metadata/route.ts b/src/app/api/saml/metadata/route.ts new file mode 100644 index 0000000..86d385d --- /dev/null +++ b/src/app/api/saml/metadata/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { generateSAMLMetadata } from '../../../../lib/saml/metadata'; + +/** + * SAML IdP Metadata Endpoint + * GET /api/saml/metadata + * + * Returns SAML 2.0 EntityDescriptor XML for this Identity Provider + */ +export async function GET() { + try { + const metadata = generateSAMLMetadata(); + + return new NextResponse(metadata, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'public, max-age=86400', // Cache for 24 hours + }, + }); + } catch (error) { + console.error('SAML Metadata error:', error); + return NextResponse.json( + { error: 'Failed to generate SAML metadata' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 9eae5d9..c6a7d11 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -35,18 +35,20 @@ export async function POST(req: NextRequest) { ); } - const { username, password } = validationResult.data; + const { username, password, email, tags } = validationResult.data; // Hash password first const passHash = await hashPassword(password); try { // createUser handles race conditions internally - const newUser = createUser(username, passHash); + const newUser = createUser(username, passHash, email, tags); return NextResponse.json({ id: newUser.id, username: newUser.username, + email: newUser.email, + tags: newUser.tags, created_at: newUser.created_at }); } catch (e: unknown) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9476b1d..dc800d5 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -10,10 +10,26 @@ export default function LoginPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); + const [providers, setProviders] = useState<{ name: string; slug: string }[]>([]); const router = useRouter(); - // Check if onboarding is needed + // Check if onboarding is needed & Check for errors & fetch providers useEffect(() => { + // Parse URL params for errors + const params = new URLSearchParams(window.location.search); + const errorMsg = params.get('error'); + if (errorMsg) { + setError(decodeURIComponent(errorMsg)); + } + + // Fetch Providers + fetch('/api/auth/providers') + .then(res => res.json()) + .then(data => { + if (Array.isArray(data)) setProviders(data); + }) + .catch(console.error); + // Safety timeout in case fetch hangs const timeout = setTimeout(() => setChecking(false), 5000); @@ -110,6 +126,31 @@ export default function LoginPage() { {loading ? 'Signing in...' : 'Sign In'} + + {providers.length > 0 && ( +
+
+ Or continue with +
+
+ {providers.map(p => ( + + ))} +
+
+ )} ); diff --git a/src/app/oauth/consent/page.module.css b/src/app/oauth/consent/page.module.css new file mode 100644 index 0000000..f643b70 --- /dev/null +++ b/src/app/oauth/consent/page.module.css @@ -0,0 +1,132 @@ +.container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 2.5rem; + width: 100%; + max-width: 500px; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); + text-align: center; +} + +.clientInfo { + margin-bottom: 2rem; + text-align: center; +} + +.clientName { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.clientDescription { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.permissions { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.permissions h3 { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.permissions ul { + list-style: none; + padding: 0; + margin: 0; +} + +.permissions li { + padding: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; + display: flex; + align-items: center; +} + +.permissions li::before { + content: '✓'; + color: var(--accent-color); + font-weight: bold; + margin-right: 0.75rem; +} + +.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + padding: 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + margin-bottom: 1.5rem; +} + +.buttonGroup { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.button { + background: var(--accent-color); + color: #fff; + border: none; + padding: 0.85rem 1.5rem; + border-radius: var(--radius-sm); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + flex: 1; +} + +.button:hover { + opacity: 0.9; +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.buttonSecondary { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.buttonSecondary:hover { + background: var(--bg-hover); + opacity: 1; +} diff --git a/src/app/oauth/consent/page.tsx b/src/app/oauth/consent/page.tsx new file mode 100644 index 0000000..a2654fc --- /dev/null +++ b/src/app/oauth/consent/page.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { getOAuthClientByClientId } from '@/lib/db-oauth'; +import styles from './page.module.css'; + +function ConsentContent() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [clientInfo, setClientInfo] = useState<{ + name: string; + description?: string; + } | null>(null); + + const router = useRouter(); + const searchParams = useSearchParams(); + + const client_id = searchParams.get('client_id') || ''; + const redirect_uri = searchParams.get('redirect_uri') || ''; + const scope = searchParams.get('scope') || 'openid'; + const state = searchParams.get('state') || ''; + const code_challenge = searchParams.get('code_challenge') || ''; + const code_challenge_method = searchParams.get('code_challenge_method') || ''; + + const scopes = scope.split(' ').filter(s => s.length > 0); + + // Fetch client information + useEffect(() => { + if (!client_id) { + setError('Invalid request: missing client_id'); + return; + } + + // Fetch client details for display + fetch(`/api/oauth/clients?client_id=${encodeURIComponent(client_id)}`) + .then(res => res.json()) + .then(data => { + if (data.client) { + setClientInfo({ + name: data.client.name, + description: data.client.description, + }); + } else { + setError('Application not found'); + } + }) + .catch(() => { + setError('Failed to load application information'); + }); + }, [client_id]); + + const handleApprove = async () => { + setLoading(true); + setError(''); + + try { + const res = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method, + approved: true, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error_description || 'Authorization failed'); + setLoading(false); + return; + } + + // Redirect back to application + window.location.href = data.redirect_uri; + } catch { + setError('Something went wrong'); + setLoading(false); + } + }; + + const handleDeny = async () => { + setLoading(true); + + try { + const res = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + client_id, + redirect_uri, + scope, + state, + approved: false, + }), + }); + + const data = await res.json(); + + // Redirect back to application with error + window.location.href = data.redirect_uri; + } catch { + setError('Something went wrong'); + setLoading(false); + } + }; + + const getScopeDescription = (scope: string): string => { + const descriptions: Record = { + openid: 'Access your basic profile information', + profile: 'Access your username and profile', + username: 'Access your username', + email: 'Access your email address', + }; + return descriptions[scope] || scope; + }; + + if (error && !clientInfo) { + return ( +
+
+

Authorization Error

+
{error}
+
+
+ ); + } + + return ( +
+
+

Authorize Application

+ + {clientInfo && ( + <> +
+

{clientInfo.name}

+ {clientInfo.description && ( +

{clientInfo.description}

+ )} +

wants to access your Atom account

+
+ +
+

This application will be able to:

+
    + {scopes.map(scope => ( +
  • {getScopeDescription(scope)}
  • + ))} +
+
+ + {error &&
{error}
} + +
+ + +
+ + )} +
+
+ ); +} + +export default function ConsentPage() { + return ( + +
+

Loading...

+
+ + }> + +
+ ); +} diff --git a/src/app/onboard/page.tsx b/src/app/onboard/page.tsx index aba9925..84fa414 100644 --- a/src/app/onboard/page.tsx +++ b/src/app/onboard/page.tsx @@ -6,6 +6,7 @@ import styles from './page.module.css'; export default function OnboardPage() { const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); @@ -32,7 +33,7 @@ export default function OnboardPage() { const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, email, password }), }); const data = await res.json(); @@ -70,6 +71,17 @@ export default function OnboardPage() { /> +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + required + /> +
+
(null); const [localTitle, setLocalTitle] = useState(''); const [localLocation, setLocalLocation] = useState(''); + const [activeSection, setActiveSection] = useState('general'); useEffect(() => { if (config) { @@ -160,6 +163,256 @@ export default function SettingsPage() { reader.readAsText(file); }; + const renderContent = () => { + switch (activeSection) { + case 'general': + return ( +
+

General

+

Show or hide widgets on your dashboard.

+ +
+ +
updateLayout('showWidgets', !config?.layout?.showWidgets)} + > +
+
+
+ +
+ +
+ setLocalTitle(e.target.value)} + placeholder="Dashboard Title" + /> + +
+
+ +
+ +
+ setLocalLocation(e.target.value)} + placeholder="City (e.g. Hyderabad)" + /> + +
+
+ +
+ + +
+ +
+ + +
+
+ ); + case 'applications': + return ( +
+

Applications

+ +
+ +
updateLayout('fullSizeButtons', !config?.layout?.fullSizeButtons)} + > +
+
+
+ +
+ + + +
+
+ ); + case 'bookmarks': + return ( +
+

Bookmarks

+
+ + + +
+
+ ); + case 'widgets': + return ( +
+

Widgets

+
+ + + +
+
+ ); + case 'users': + return ( +
+

Users

+ +
+ ); + case 'sso': + return ( +
+

SSO Provider

+

+ Configure OAuth2/OIDC applications that can authenticate through Atom. + Applications you add here can use Atom as their identity provider for single sign-on. +

+ +
+

OAuth2/OIDC Endpoints

+

Use these endpoints when configuring OAuth2 or OIDC in your applications:

+
    +
  • Discovery (OIDC): {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/.well-known/openid-configuration
  • +
  • Authorization: {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/oauth/authorize
  • +
  • Token: {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/oauth/token
  • +
  • UserInfo: {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/oauth/userinfo
  • +
  • JWKS: {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/.well-known/jwks.json
  • +
+

+ Supported scopes: openid, profile, username, email +

+

+ For OIDC-compliant apps (oauth2-proxy, Grafana, etc.), use the Discovery URL for auto-configuration. +

+
+ + +
+ ); + case 'external': + return ( +
+

External Login Providers

+

+ Allow users to sign in to Atom using external services like Google, Authentik, or GitHub. +

+
+ +
+
+ ); + case 'backup': + return ( +
+

Data & Backup

+
+ + + + +
+
+ ); + default: + return null; + } + }; + if (loading || !config) return
Loading...
; return ( @@ -182,198 +435,46 @@ export default function SettingsPage() { > {theme === 'dark' ? : } - Back + Back to Dashboard
-
-

General

-

Show or hide widgets on your dashboard.

- -
- -
updateLayout('showWidgets', !config.layout?.showWidgets)} - > -
+
+
- -
- -
- setLocalTitle(e.target.value)} - placeholder="Dashboard Title" - /> - +
Content
+
setActiveSection('applications')}> + Applications
-
- -
- -
- setLocalLocation(e.target.value)} - placeholder="City (e.g. Hyderabad)" - /> - +
setActiveSection('bookmarks')}> + Bookmarks
-
- -
- - -
- -
- - -
-
- -
-

Applications

- -
- -
updateLayout('fullSizeButtons', !config.layout?.fullSizeButtons)} - > -
+
setActiveSection('widgets')}> + Widgets
-
- -
- - - -
-
- -
-

Bookmarks

-
- - - -
-
+
Access & Security
+
setActiveSection('users')}> + Users +
+
setActiveSection('sso')}> + SSO Provider +
+
setActiveSection('external')}> + External Login +
+
System
+
setActiveSection('backup')}> + Data & Backup +
+ -
-

Widgets

-
- - - -
-
- -
-

Users

- -
- -
-

Data & Backup

-
- - - - -
-
+
+ {renderContent()} +
+ {/* Modals */} {activeModal === 'add-app' && ( diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ed57f17..8f34552 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -15,7 +15,7 @@ import styles from './Dashboard.module.css'; import { useConfig } from '@/context/ConfigContext'; -export default function Dashboard({ user }: { user?: { username: string } }) { +export default function Dashboard({ user }: { user?: { username: string; tags?: string[] } }) { const { config, updateConfig, loading } = useConfig(); const [search, setSearch] = useState(''); const [layout, setLayout] = useState<'list' | 'grid4' | 'grid6'>('grid6'); @@ -26,7 +26,6 @@ export default function Dashboard({ user }: { user?: { username: string } }) { // Initial Status Check useEffect(() => { - if (!config?.services) return; if (!config?.services) return; // Fire and forget, context handles throttling checkMany(config.services); @@ -118,10 +117,23 @@ export default function Dashboard({ user }: { user?: { username: string } }) { return 'Good Evening'; }; - const filteredServices = config.services.filter(s => - s.name.toLowerCase().includes(search.toLowerCase()) || - s.url.toLowerCase().includes(search.toLowerCase()) - ); + const userTags = user?.tags || []; + const hasAllAccess = userTags.includes('all'); + + const filteredServices = config.services.filter(s => { + // Tag Access Control + if (!hasAllAccess) { + const serviceTags = s.tags || []; + if (serviceTags.length > 0) { + const hasMatchingTag = serviceTags.some(t => userTags.includes(t)); + if (!hasMatchingTag) return false; + } + } + + // Search Filter + return s.name.toLowerCase().includes(search.toLowerCase()) || + s.url.toLowerCase().includes(search.toLowerCase()); + }); const getSearchUrl = (query: string) => { const searchEngines: { [key: string]: string } = { diff --git a/src/components/modals/AddServiceModal.tsx b/src/components/modals/AddServiceModal.tsx index 72d34f4..55ed6bf 100644 --- a/src/components/modals/AddServiceModal.tsx +++ b/src/components/modals/AddServiceModal.tsx @@ -28,8 +28,10 @@ export default function AddServiceModal({ onClose, onSave, category = 'General', url: initialData?.url || '', description: initialData?.description || '', icon: initialData?.icon || '', - category: initialData?.category || category + category: initialData?.category || category, + tags: initialData?.tags || [] }); + const [tagsInput, setTagsInput] = useState(initialData?.tags?.join(', ') || ''); const [iconQuery, setIconQuery] = useState(''); const filteredIcons = iconQuery @@ -53,6 +55,7 @@ export default function AddServiceModal({ onClose, onSave, category = 'General', description: formData.description, icon: formData.icon || 'box', category: formData.category, + tags: tagsInput.split(',').map(t => t.trim()).filter(Boolean), createdAt: initialData?.createdAt || Date.now(), updatedAt: Date.now() }; @@ -60,14 +63,15 @@ export default function AddServiceModal({ onClose, onSave, category = 'General', onSave(newService); if (addNew) { - // Reset form but keep category setFormData({ name: '', url: '', description: '', icon: '', - category + category, + tags: [] }); + setTagsInput(''); setIconQuery(''); } else { onClose(); @@ -114,6 +118,16 @@ export default function AddServiceModal({ onClose, onSave, category = 'General', Optional description of the application. +
+ + setTagsInput(e.target.value)} + /> + Comma-sparated tags for access control +
+
diff --git a/src/components/oauth/AuthProviderManager.tsx b/src/components/oauth/AuthProviderManager.tsx new file mode 100644 index 0000000..5731355 --- /dev/null +++ b/src/components/oauth/AuthProviderManager.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Plus, Trash2, Globe, Key, Shield, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import styles from './ClientManager.module.css'; // Reuse existing styles if compatible or duplicate + +interface AuthProvider { + name: string; + slug: string; + issuer: string; + client_id: string; + enabled: boolean; +} + +export default function AuthProviderManager() { + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + + // Form State + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [issuer, setIssuer] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + + const fetchProviders = async () => { + try { + const res = await fetch('/api/auth/providers?all=true'); + if (res.ok) { + const data = await res.json(); + setProviders(data); + } + } catch (error) { + console.error(error); + toast.error('Failed to load identity providers'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchProviders(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + const res = await fetch('/api/auth/providers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + slug: slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'), // Basic slugify + issuer, + client_id: clientId, + client_secret: clientSecret, + enabled: true + }) + }); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || 'Failed to create provider'); + + toast.success('Identity Provider added successfully'); + setShowForm(false); + + // Reset form + setName(''); + setSlug(''); + setIssuer(''); + setClientId(''); + setClientSecret(''); + + fetchProviders(); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : 'An error occurred'; + toast.error(errorMsg); + } + }; + + const handleDelete = async (slugToDelete: string) => { + if (!confirm('Are you sure you want to delete this provider? Users linked to this provider will usually remain, but cannot login via this method anymore.')) return; + + try { + const res = await fetch('/api/auth/providers', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: slugToDelete }) + }); + + if (!res.ok) throw new Error('Failed to delete provider'); + + toast.success('Provider deleted'); + fetchProviders(); + } catch (error) { + toast.error('Failed to delete provider'); + } + }; + + return ( +
+
+
+

Identity Providers

+

Configure external services (Google, Authentik, etc.) for Single Sign-On.

+
+ {!showForm && ( + + )} +
+ + {showForm && ( +
+
+
+ + { + setName(e.target.value); + if (!slug) setSlug(e.target.value.toLowerCase().replace(/\s+/g, '-')); + }} + required + /> +
+
+ + setSlug(e.target.value)} + required + /> + + Used in URL: /api/auth/{slug || '...'}/login + +
+
+ +
+ + setIssuer(e.target.value)} + required + /> +
+
+
+
+ + setClientId(e.target.value)} + required + /> +
+
+ + setClientSecret(e.target.value)} + required + /> +
+
+ +
+ + +
+
+
+ )} + +
+ {loading ? ( +
Loading providers...
+ ) : providers.length === 0 ? ( +
No identity providers configured.
+ ) : ( + providers.map(p => ( +
+
+
+ {p.name} + {p.slug} +
+
+ {p.issuer} +
+
+ +
+ )) + )} +
+
+ ); +} diff --git a/src/components/oauth/ClientManager.module.css b/src/components/oauth/ClientManager.module.css new file mode 100644 index 0000000..b757953 --- /dev/null +++ b/src/components/oauth/ClientManager.module.css @@ -0,0 +1,521 @@ +.container { + padding: 1.5rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.addButton { + background: var(--accent-color); + color: #fff; + border: none; + padding: 0.6rem 1.2rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: opacity 0.2s; +} + +.addButton:hover { + opacity: 0.9; +} + +.createdClientCard { + background: var(--bg-card); + border: 2px solid var(--accent-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.successBanner { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--accent-color); + font-weight: 500; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +.credentialRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-primary); + border-radius: var(--radius-sm); +} + +.credentialRow label { + display: block; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.3rem; +} + +.credentialRow code { + display: block; + font-family: 'Monaco', 'Courier', monospace; + font-size: 0.85rem; + color: var(--text-primary); +} + +.credentialRow button { + background: var(--bg-hover); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.5rem; + cursor: pointer; + color: var(--text-primary); + transition: background 0.2s; + min-width: 70px; + font-size: 0.85rem; +} + +.credentialRow button:hover { + background: var(--bg-card); +} + +.dismissButton { + width: 100%; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 0.6rem; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; +} + +.formCard { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.formCard h3 { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1.5rem; +} + +.formGroup { + margin-bottom: 1.25rem; +} + +.formGroup label { + display: block; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.formGroup input { + width: 100%; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; +} + +.formGroup input:focus { + outline: none; + border-color: var(--accent-color); +} + +.uriRow { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.uriRow input { + flex: 1; +} + +.removeButton { + padding: 0.75rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.2s; +} + +.removeButton:hover { + background: rgba(239, 68, 68, 0.2); +} + +.addUriButton { + padding: 0.5rem 1rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.85rem; + transition: background 0.2s; +} + +.addUriButton:hover { + background: var(--bg-hover); +} + +.formActions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.cancelButton { + flex: 1; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; +} + +.saveButton { + flex: 1; + padding: 0.75rem; + background: var(--accent-color); + border: none; + color: #fff; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; +} + +.saveButton:hover { + opacity: 0.9; +} + +.emptyState { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.emptyHint { + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.clientList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.clientCard { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +.clientHeader { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; +} + +.clientHeader h4 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.3rem; +} + +.clientHeader p { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.deleteButton { + padding: 0.5rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.2s; +} + +.deleteButton:hover { + background: rgba(239, 68, 68, 0.2); +} + +.clientDetails { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.detailRow { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.9rem; +} + +.detailRow>span:first-child { + color: var(--text-secondary); + font-weight: 500; + min-width: 110px; +} + +.detailRow code { + font-family: 'Monaco', 'Courier', monospace; + font-size: 0.85rem; + background: var(--bg-primary); + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + color: var(--text-primary); +} + +.detailRow button { + padding: 0.3rem 0.6rem; + background: var(--bg-hover); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); + font-size: 0.8rem; +} + +.scopes { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.scope { + background: var(--accent-color); + color: #fff; + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 500; +} + +.uris { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.meta { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.actionButtons { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +.dockerButton { + flex: 1; + padding: 0.75rem; + background: var(--bg-hover); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: background 0.2s; +} + +.dockerButton:hover { + background: var(--bg-primary); +} + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modalContent { + background: var(--bg-card); + border-radius: var(--radius-lg); + max-width: 700px; + width: 100%; + max-height: 90vh; + overflow: auto; +} + +.modalHeader { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modalHeader h3 { + font-size: 1.2rem; + color: var(--text-primary); + margin: 0; +} + +.modalHeader button { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; +} + +.modalBody { + padding: 1.5rem; +} + +.modalBody p { + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.codeBlock { + position: relative; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 1rem; + margin: 1rem 0; +} + +.copyCodeButton { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.5rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); +} + +.codeBlock pre { + margin: 0; + overflow-x: auto; + font-size: 0.85rem; +} + +.codeBlock code { + font-family: 'Monaco', 'Courier', monospace; + color: var(--text-primary); +} + +.setupSteps { + margin-top: 1.5rem; + background: var(--bg-primary); + padding: 1rem; + border-radius: var(--radius-sm); +} + +.setupSteps h4 { + margin: 0 0 0.75rem 0; + color: var(--text-primary); + font-size: 0.95rem; +} + +.setupSteps ol { + margin: 0; + padding-left: 1.5rem; +} + +.setupSteps li { + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.setupSteps code { + background: var(--bg-card); + padding: 0.2rem 0.4rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + color: var(--accent-color); +} + +.scopeSelector { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 0.5rem; +} + +.scopeCheckbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; + color: var(--text-primary); +} + +.scopeCheckbox input[type="checkbox"] { + cursor: pointer; + width: 18px; + height: 18px; + margin: 0; +} + +.scopeCheckbox span { + margin-left: 0.5rem; +} + +.hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} \ No newline at end of file diff --git a/src/components/oauth/ClientManager.tsx b/src/components/oauth/ClientManager.tsx new file mode 100644 index 0000000..d46354e --- /dev/null +++ b/src/components/oauth/ClientManager.tsx @@ -0,0 +1,435 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Plus, Copy, Trash2, CheckCircle2, FileCode, Edit3 } from 'lucide-react'; +import styles from './ClientManager.module.css'; + +interface OAuthClient { + id: number; + client_id: string; + name: string; + description?: string; + redirect_uris: string[]; + allowed_scopes: string[]; + grant_types: string[]; + created_at: string; +} + +export default function ClientManager() { + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [editingClientId, setEditingClientId] = useState(null); + const [newClient, setNewClient] = useState({ + name: '', + description: '', + redirect_uris: [''], + allowed_scopes: ['openid', 'username'], + }); + const [createdClient, setCreatedClient] = useState<(OAuthClient & { client_secret: string }) | null>(null); + const [copiedField, setCopiedField] = useState(null); + const [showDockerCompose, setShowDockerCompose] = useState(false); + + useEffect(() => { + fetchClients(); + }, []); + + const fetchClients = async () => { + try { + const res = await fetch('/api/oauth/clients', { cache: 'no-store' }); + const data = await res.json(); + setClients(data.clients || []); + } catch (error) { + console.error('Failed to fetch clients:', error); + } finally { + setLoading(false); + } + }; + + const handleSaveClient = async () => { + try { + const isEdit = !!editingClientId; + const url = '/api/oauth/clients'; + const method = isEdit ? 'PATCH' : 'POST'; + + const body = { + ...newClient, + redirect_uris: newClient.redirect_uris.filter(uri => uri.length > 0), + ...(isEdit ? { client_id: editingClientId } : {}) + }; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.ok) { + const data = await res.json(); + + if (!isEdit) { + setCreatedClient(data.client); + } + + setNewClient({ name: '', description: '', redirect_uris: [''], allowed_scopes: ['openid', 'profile'] }); + setShowAddForm(false); + setEditingClientId(null); + fetchClients(); + } else { + const error = await res.json(); + alert(error.message || `Failed to ${isEdit ? 'update' : 'create'} client`); + } + } catch (error) { + console.error(`Failed to ${editingClientId ? 'update' : 'create'} client:`, error); + alert(`Failed to ${editingClientId ? 'update' : 'create'} client`); + } + }; + + const handleEditClient = (client: OAuthClient) => { + setNewClient({ + name: client.name, + description: client.description || '', + redirect_uris: client.redirect_uris, + allowed_scopes: client.allowed_scopes, + }); + setEditingClientId(client.client_id); + setShowAddForm(true); + }; + + const handleCancel = () => { + setShowAddForm(false); + setEditingClientId(null); + setNewClient({ name: '', description: '', redirect_uris: [''], allowed_scopes: ['openid', 'profile'] }); + } + + const handleDeleteClient = async (client_id: string) => { + if (!confirm('Are you sure you want to delete this application? This action cannot be undone.')) { + return; + } + + try { + const res = await fetch(`/api/oauth/clients?client_id=${client_id}`, { + method: 'DELETE', + }); + + if (res.ok) { + fetchClients(); + } else { + alert('Failed to delete client'); + } + } catch (error) { + console.error('Failed to delete client:', error); + alert('Failed to delete client'); + } + }; + + const copyToClipboard = (text: string, field: string) => { + navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }; + + const addRedirectUri = () => { + setNewClient({ ...newClient, redirect_uris: [...newClient.redirect_uris, ''] }); + }; + + const updateRedirectUri = (index: number, value: string) => { + const updated = [...newClient.redirect_uris]; + updated[index] = value; + setNewClient({ ...newClient, redirect_uris: updated }); + }; + + const removeRedirectUri = (index: number) => { + const updated = newClient.redirect_uris.filter((_, i) => i !== index); + setNewClient({ ...newClient, redirect_uris: updated }); + }; + + const generateDockerCompose = (client: OAuthClient & { client_secret: string }) => { + const appName = client.name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + const redirectUri = client.redirect_uris[0] || 'http://localhost:PORT/oauth2/callback'; + + return `version: '3.8' + +services: + # Your Application + ${appName}: + image: your-app-image:latest + container_name: ${appName} + # Add your app configuration here + networks: + - app_network + + # oauth2-proxy for SSO + ${appName}-auth: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + container_name: ${appName}-auth + command: + - --provider=oidc + - --oidc-issuer-url=${typeof window !== 'undefined' ? window.location.origin : 'http://atom:3000'} + - --client-id=${client.client_id} + - --client-secret=${client.client_secret} + - --cookie-secret=COOKIE_SECRET_HERE # Generate with: openssl rand -base64 32 + - --redirect-url=${redirectUri} + - --upstream=http://${appName}:PORT # Update with your app's port + - --email-domain=* + - --pass-user-headers=true + - --pass-access-token=true + - --cookie-secure=false # Set to true in production with HTTPS + - --http-address=0.0.0.0:4180 + ports: + - "PORT:4180" # Update PORT (e.g., 8080:4180) + networks: + - app_network + depends_on: + - ${appName} + restart: unless-stopped + +networks: + app_network: + driver: bridge`; + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

OAuth2 Applications

+ +
+ + {/* Show created client credentials */} + {createdClient && ( +
+
+ + Application created successfully! Save these credentials securely - the client secret won't be shown again. +
+ +
+
+ + {createdClient.client_id} +
+ +
+ +
+
+ + {createdClient.client_secret} +
+ +
+ +
+ + +
+
+ )} + + {/* Add Client Form */} + {showAddForm && ( +
+

{editingClientId ? 'Edit Application' : 'New Application'}

+ +
+ + setNewClient({ ...newClient, name: e.target.value })} + placeholder="My Application" + /> +
+ +
+ + setNewClient({ ...newClient, description: e.target.value })} + placeholder="Optional description" + /> +
+ +
+ + {newClient.redirect_uris.map((uri, index) => ( +
+ updateRedirectUri(index, e.target.value)} + placeholder="https://example.com/callback" + /> + {newClient.redirect_uris.length > 1 && ( + + )} +
+ ))} + +
+ +
+ +
+ {['openid', 'profile', 'username', 'email'].map(scope => ( + + ))} +
+

Select which scopes this application can request. 'openid' is required for OIDC.

+
+ +
+ + +
+
+ )} + + {/* Client List */} + {clients.length === 0 ? ( +
+

No OAuth2 applications configured

+

Add an application to enable SSO authentication

+
+ ) : ( +
+ {clients.map((client) => ( +
+
+
+

{client.name}

+ {client.description &&

{client.description}

} +
+
+ + +
+
+ +
+
+ Client ID: + {client.client_id} + +
+ +
+ Scopes: +
+ {client.allowed_scopes.map(scope => ( + {scope} + ))} +
+
+ +
+ Redirect URIs: +
+ {client.redirect_uris.map((uri, idx) => ( + {uri} + ))} +
+
+
+
+ ))} +
+ )} + + {/* Docker Compose Modal */} + {showDockerCompose && createdClient && ( +
setShowDockerCompose(false)}> +
e.stopPropagation()}> +
+

Docker Compose Configuration

+ +
+
+

Use this docker-compose configuration to deploy oauth2-proxy with your application:

+
+ +
{generateDockerCompose(createdClient)}
+
+
+

Setup Steps:

+
    +
  1. Generate cookie secret: openssl rand -base64 32
  2. +
  3. Replace COOKIE_SECRET_HERE with the generated value
  4. +
  5. Update redirect-url with your actual domain
  6. +
  7. Deploy: docker-compose up -d
  8. +
+
+
+
+
+ )} +
+ ); +} diff --git a/src/components/proxy/ProxyManager.module.css b/src/components/proxy/ProxyManager.module.css new file mode 100644 index 0000000..0303287 --- /dev/null +++ b/src/components/proxy/ProxyManager.module.css @@ -0,0 +1,271 @@ +.container { + padding: 1.5rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.addButton { + background: var(--accent-color); + color: #fff; + border: none; + padding: 0.6rem 1.2rem; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + transition: opacity 0.2s; +} + +.addButton:hover { + opacity: 0.9; +} + +.formCard { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.formCard h3 { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1.5rem; +} + +.formGroup { + margin-bottom: 1.25rem; +} + +.formGroup label { + display: block; + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 500; +} + +.formGroup label input[type="checkbox"] { + margin-right: 0.5rem; +} + +.formGroup label span { + color: var(--text-primary); +} + +.hint { + font-size: 0.8rem; + color: var(--text-muted); + font-weight: normal; +} + +.formGroup input[type="text"], +.formGroup input[type="url"] { + width: 100%; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; +} + +.formGroup input:focus { + outline: none; + border-color: var(--accent-color); +} + +.preview { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.preview code { + color: var(--accent-color); + background: var(--bg-primary); + padding: 0.2rem 0.4rem; + border-radius: var(--radius-sm); +} + +.formActions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.cancelButton { + flex: 1; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; +} + +.saveButton { + flex: 1; + padding: 0.75rem; + background: var(--accent-color); + border: none; + color: #fff; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; +} + +.saveButton:hover { + opacity: 0.9; +} + +.emptyState { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.emptyHint { + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.appList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.appCard { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +.appHeader { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 1rem; +} + +.appHeader h4 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.3rem; +} + +.slug { + font-size: 0.85rem; + color: var(--text-muted); + background: var(--bg-primary); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); +} + +.deleteButton { + padding: 0.5rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.2s; +} + +.deleteButton:hover { + background: rgba(239, 68, 68, 0.2); +} + +.appDetails { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.detailRow { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.9rem; +} + +.detailRow>span:first-child { + color: var(--text-secondary); + font-weight: 500; + min-width: 110px; +} + +.detailRow code { + font-family: 'Monaco', 'Courier', monospace; + font-size: 0.85rem; + background: var(--bg-primary); + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + color: var(--text-primary); + flex: 1; +} + +.detailRow button, +.detailRow a { + padding: 0.3rem 0.6rem; + background: var(--bg-hover); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); + font-size: 0.8rem; + text-decoration: none; + display: flex; + align-items: center; +} + +.yes { + color: #22c55e; + font-weight: 500; +} + +.no { + color: var(--text-muted); +} + +.users { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.user { + background: var(--accent-color); + color: #fff; + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 500; +} + +.meta { + font-size: 0.85rem; + color: var(--text-secondary); +} \ No newline at end of file diff --git a/src/components/proxy/ProxyManager.tsx b/src/components/proxy/ProxyManager.tsx new file mode 100644 index 0000000..3548776 --- /dev/null +++ b/src/components/proxy/ProxyManager.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Plus, Copy, Trash2, ExternalLink } from 'lucide-react'; +import styles from './ProxyManager.module.css'; + +interface ProtectedApp { + id: number; + name: string; + slug: string; + backend_url: string; + require_auth: boolean; + allowed_users: string[] | null; + inject_headers: boolean; + strip_auth_header: boolean; + created_at: string; +} + +export default function ProxyManager() { + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [newApp, setNewApp] = useState({ + name: '', + slug: '', + backend_url: '', + require_auth: true, + allowed_users: '', + inject_headers: true, + strip_auth_header: true, + }); + const [copiedUrl, setCopiedUrl] = useState(null); + + useEffect(() => { + fetchApps(); + }, []); + + const fetchApps = async () => { + try { + const res = await fetch('/api/proxy/apps'); + const data = await res.json(); + setApps(data.applications || []); + } catch (error) { + console.error('Failed to fetch protected apps:', error); + } finally { + setLoading(false); + } + }; + + const handleAddApp = async () => { + try { + const payload = { + ...newApp, + allowed_users: newApp.allowed_users + ? newApp.allowed_users.split(',').map(u => u.trim()).filter(u => u.length > 0) + : null, + }; + + const res = await fetch('/api/proxy/apps', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + setNewApp({ + name: '', + slug: '', + backend_url: '', + require_auth: true, + allowed_users: '', + inject_headers: true, + strip_auth_header: true, + }); + setShowAddForm(false); + fetchApps(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to create protected app'); + } + } catch (error) { + console.error('Failed to create protected app:', error); + alert('Failed to create protected app'); + } + }; + + const handleDeleteApp = async (slug: string) => { + if (!confirm(`Are you sure you want to delete this protected application?`)) { + return; + } + + try { + const res = await fetch(`/api/proxy/apps?slug=${slug}`, { + method: 'DELETE', + }); + + if (res.ok) { + fetchApps(); + } else { + alert('Failed to delete protected app'); + } + } catch (error) { + console.error('Failed to delete protected app:', error); + alert('Failed to delete protected app'); + } + }; + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedUrl(id); + setTimeout(() => setCopiedUrl(null), 2000); + }; + + const getProxyUrl = (slug: string) => { + if (typeof window === 'undefined') return ''; + return `${window.location.origin}/api/proxy/${slug}/`; + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

Protected Applications

+ +
+ + {/* Add App Form */} + {showAddForm && ( +
+

New Protected Application

+ +
+ + setNewApp({ ...newApp, name: e.target.value })} + placeholder="Grafana" + /> +
+ +
+ + setNewApp({ ...newApp, slug: e.target.value.toLowerCase() })} + placeholder="grafana" + pattern="[a-z0-9-]+" + /> + {newApp.slug && ( +
+ Proxy URL: {getProxyUrl(newApp.slug)}* +
+ )} +
+ +
+ + setNewApp({ ...newApp, backend_url: e.target.value })} + placeholder="http://grafana:3000" + /> +
+ +
+ +
+ +
+ + setNewApp({ ...newApp, allowed_users: e.target.value })} + placeholder="admin, user1, user2" + /> +
+ +
+ +
+ +
+ + +
+
+ )} + + {/* App List */} + {apps.length === 0 ? ( +
+

No protected applications configured

+

Add an application to enable authentication proxy

+
+ ) : ( +
+ {apps.map((app) => ( +
+
+
+

{app.name}

+ {app.slug} +
+ +
+ +
+
+ Backend URL: + {app.backend_url} + + + +
+ +
+ Proxy URL: + {getProxyUrl(app.slug)}* + +
+ +
+ Auth Required: + + {app.require_auth ? 'Yes' : 'No'} + +
+ + {app.allowed_users && app.allowed_users.length > 0 && ( +
+ Allowed Users: +
+ {app.allowed_users.map(user => ( + {user} + ))} +
+
+ )} + +
+ Headers: + + {app.inject_headers ? '✓ Inject auth headers' : '✗ No headers'} + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/ui/UserManagement.module.css b/src/components/ui/UserManagement.module.css index 8ca211b..2beba4d 100644 --- a/src/components/ui/UserManagement.module.css +++ b/src/components/ui/UserManagement.module.css @@ -41,9 +41,35 @@ .date { font-size: 0.8rem; color: var(--text-secondary); + color: var(--text-secondary); font-weight: 400; } +.email { + font-size: 0.85rem; + color: var(--text-secondary); + margin-right: 0.5rem; +} + +.tags { + display: flex; + gap: 0.3rem; + margin-right: auto; +} + +.tag { + font-size: 0.75rem; + background: var(--bg-hover); + padding: 0.1rem 0.4rem; + border-radius: 4px; + color: var(--text-secondary); +} + +.hint { + font-size: 0.75rem; + color: var(--text-muted); +} + .actions { display: flex; gap: 0.5rem; diff --git a/src/components/ui/UserManagement.tsx b/src/components/ui/UserManagement.tsx index db15bfe..84e85e2 100644 --- a/src/components/ui/UserManagement.tsx +++ b/src/components/ui/UserManagement.tsx @@ -8,6 +8,8 @@ import styles from './UserManagement.module.css'; interface UserData { id: number; username: string; + email?: string; + tags?: string[]; created_at: string; } @@ -21,6 +23,8 @@ export default function UserManagement() { // Form states const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [email, setEmail] = useState(''); + const [tags, setTags] = useState(''); const [error, setError] = useState(''); useEffect(() => { @@ -42,7 +46,7 @@ export default function UserManagement() { }; const handleAddUser = async () => { - if (!username || !password) { + if (!username || !password || !email) { setError('All fields required'); return; } @@ -55,7 +59,12 @@ export default function UserManagement() { const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ + username, + password, + email, + tags: tags.split(',').map(t => t.trim()).filter(Boolean) + }) }); const data = await res.json(); @@ -130,6 +139,8 @@ export default function UserManagement() { const resetForm = () => { setUsername(''); setPassword(''); + setEmail(''); + setTags(''); setError(''); setSelectedUser(null); }; @@ -144,6 +155,12 @@ export default function UserManagement() {
{u.username} + {u.email && ({u.email})} + {u.tags && u.tags.length > 0 && ( + + {u.tags.map(t => {t})} + + )} Member since {new Date(u.created_at).toLocaleDateString()}
@@ -183,15 +200,35 @@ export default function UserManagement() {
{showAddModal && ( -
- - setUsername(e.target.value)} - placeholder="Username" - /> -
+ <> +
+ + setUsername(e.target.value)} + placeholder="Username" + /> +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setTags(e.target.value)} + placeholder="admin, dev, finance" + /> + Comma-sparated tags for access control +
+ )}
@@ -216,7 +253,8 @@ export default function UserManagement() {
- )} -
+ ) + } +
); } diff --git a/src/lib/auth-providers.ts b/src/lib/auth-providers.ts new file mode 100644 index 0000000..f1b29d7 --- /dev/null +++ b/src/lib/auth-providers.ts @@ -0,0 +1,139 @@ +import db from './db'; +import Database from 'better-sqlite3'; + +// Types +export interface AuthProvider { + id: number; + name: string; + slug: string; + issuer: string; + client_id: string; + client_secret: string; + authorization_endpoint?: string; + token_endpoint?: string; + userinfo_endpoint?: string; + jwks_uri?: string; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface FederatedIdentity { + id: number; + user_id: number; + provider_slug: string; + subject: string; + email?: string; + created_at: string; +} + +// Prepared Statement Helpers +const preparedStmts = new Map(); + +function getStmt(sql: string): Database.Statement { + if (!preparedStmts.has(sql)) { + preparedStmts.set(sql, db.prepare(sql)); + } + return preparedStmts.get(sql)!; +} + +// ============================================================================ +// Auth Provider Operations +// ============================================================================ + +export function getAuthProviderBySlug(slug: string): AuthProvider | undefined { + const row = getStmt('SELECT * FROM auth_providers WHERE slug = ?').get(slug); + return row ? { ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider : undefined; +} + +export function listAuthProviders(): AuthProvider[] { + const rows = getStmt('SELECT * FROM auth_providers ORDER BY name ASC').all(); + return rows.map(row => ({ ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider)); +} + +export function listEnabledAuthProviders(): AuthProvider[] { + const rows = getStmt('SELECT * FROM auth_providers WHERE enabled = 1 ORDER BY name ASC').all(); + return rows.map(row => ({ ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider)); +} + +export function createAuthProvider( + data: Omit +): AuthProvider { + const stmt = getStmt(` + INSERT INTO auth_providers (name, slug, issuer, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + data.name, + data.slug, + data.issuer, + data.client_id, + data.client_secret, + data.authorization_endpoint || null, + data.token_endpoint || null, + data.userinfo_endpoint || null, + data.jwks_uri || null, + data.enabled ? 1 : 0 + ); + + return getAuthProviderBySlug(data.slug)!; +} + +export function updateAuthProvider(slug: string, updates: Partial): boolean { + const current = getAuthProviderBySlug(slug); + if (!current) return false; + + const fields: string[] = []; + const values: any[] = []; + + if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } + if (updates.issuer !== undefined) { fields.push('issuer = ?'); values.push(updates.issuer); } + if (updates.client_id !== undefined) { fields.push('client_id = ?'); values.push(updates.client_id); } + if (updates.client_secret !== undefined) { fields.push('client_secret = ?'); values.push(updates.client_secret); } + if (updates.authorization_endpoint !== undefined) { fields.push('authorization_endpoint = ?'); values.push(updates.authorization_endpoint); } + if (updates.token_endpoint !== undefined) { fields.push('token_endpoint = ?'); values.push(updates.token_endpoint); } + if (updates.userinfo_endpoint !== undefined) { fields.push('userinfo_endpoint = ?'); values.push(updates.userinfo_endpoint); } + if (updates.jwks_uri !== undefined) { fields.push('jwks_uri = ?'); values.push(updates.jwks_uri); } + if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } + + if (fields.length === 0) return false; + + fields.push("updated_at = datetime('now')"); + values.push(slug); + + const sql = `UPDATE auth_providers SET ${fields.join(', ')} WHERE slug = ?`; + db.prepare(sql).run(...values); + return true; +} + +export function deleteAuthProvider(slug: string): boolean { + const result = getStmt('DELETE FROM auth_providers WHERE slug = ?').run(slug); + return result.changes > 0; +} + +// ============================================================================ +// Federated Identity Operations +// ============================================================================ + +export function getFederatedIdentity(providerSlug: string, subject: string): FederatedIdentity | undefined { + return getStmt('SELECT * FROM federated_identities WHERE provider_slug = ? AND subject = ?') + .get(providerSlug, subject) as FederatedIdentity | undefined; +} + +export function linkFederatedIdentity( + userId: number, + providerSlug: string, + subject: string, + email?: string +): FederatedIdentity { + const stmt = getStmt('INSERT INTO federated_identities (user_id, provider_slug, subject, email) VALUES (?, ?, ?, ?)'); + stmt.run(userId, providerSlug, subject, email || null); + + return getFederatedIdentity(providerSlug, subject)!; +} + +export function unlinkFederatedIdentity(providerSlug: string, subject: string): boolean { + const result = getStmt('DELETE FROM federated_identities WHERE provider_slug = ? AND subject = ?').run(providerSlug, subject); + return result.changes > 0; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9e0aa10..a501f36 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -69,16 +69,18 @@ export async function getCurrentUser() { return { id: session.user.id, username: session.user.username, + email: session.user.email, + tags: session.user.tags || [], }; } -export async function register(username: string, password: string): Promise<{ success: boolean; error?: string }> { +export async function register(username: string, password: string, email?: string): Promise<{ success: boolean; error?: string }> { // Hash password first (before DB check to prevent timing attacks) const hash = await hashPassword(password); try { // createUser will throw if username exists (prevents race condition) - const user = createUser(username, hash); + const user = createUser(username, hash, email); // Auto-login after registration const session = createSession(user.id); diff --git a/src/lib/db-oauth.ts b/src/lib/db-oauth.ts new file mode 100644 index 0000000..cd5c70e --- /dev/null +++ b/src/lib/db-oauth.ts @@ -0,0 +1,382 @@ +import Database from 'better-sqlite3'; +import db from './db'; +import type { + OAuthClient, + OAuthAuthorizationCode, + OAuthAccessToken, + OAuthRefreshToken, + OAuthUserConsent, +} from './oauth/types'; + +// Helper to get or create prepared statement +const preparedStmts = new Map(); + +function getStmt(sql: string): Database.Statement { + if (!preparedStmts.has(sql)) { + preparedStmts.set(sql, db.prepare(sql)); + } + return preparedStmts.get(sql)!; +} + +// Helper to parse JSON fields from database +function parseJsonField(data: string): T { + try { + return JSON.parse(data); + } catch { + return [] as T; + } +} + +// Helper to serialize JSON fields for database +function serializeJsonField(data: T): string { + return JSON.stringify(data); +} + +// ============================================================================ +// OAuth Client Operations +// ============================================================================ + +export function createOAuthClient( + name: string, + description: string | undefined, + redirect_uris: string[], + allowed_scopes: string[], + grant_types: string[] = ['authorization_code', 'refresh_token'], + is_confidential = true +): OAuthClient { + const client_id = crypto.randomUUID(); + const client_secret = crypto.randomUUID().replace(/-/g, ''); // Remove dashes for cleaner secret + + const stmt = getStmt(` + INSERT INTO oauth_clients (client_id, client_secret, name, description, redirect_uris, allowed_scopes, grant_types, is_confidential) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + client_id, + client_secret, + name, + description || null, + serializeJsonField(redirect_uris), + serializeJsonField(allowed_scopes), + serializeJsonField(grant_types), + is_confidential ? 1 : 0 + ); + + return getOAuthClientByClientId(client_id)!; +} + +export function getOAuthClientByClientId(client_id: string): OAuthClient | undefined { + const row = getStmt('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id) as any; + if (!row) return undefined; + + return { + ...row, + redirect_uris: parseJsonField(row.redirect_uris), + allowed_scopes: parseJsonField(row.allowed_scopes), + grant_types: parseJsonField(row.grant_types), + is_confidential: row.is_confidential === 1, + }; +} + +export function getOAuthClientById(id: number): OAuthClient | undefined { + const row = getStmt('SELECT * FROM oauth_clients WHERE id = ?').get(id) as any; + if (!row) return undefined; + + return { + ...row, + redirect_uris: parseJsonField(row.redirect_uris), + allowed_scopes: parseJsonField(row.allowed_scopes), + grant_types: parseJsonField(row.grant_types), + is_confidential: row.is_confidential === 1, + }; +} + +export function listOAuthClients(): OAuthClient[] { + const rows = getStmt('SELECT * FROM oauth_clients ORDER BY created_at DESC').all() as any[]; + return rows.map(row => ({ + ...row, + redirect_uris: parseJsonField(row.redirect_uris), + allowed_scopes: parseJsonField(row.allowed_scopes), + grant_types: parseJsonField(row.grant_types), + is_confidential: row.is_confidential === 1, + })); +} + +export function updateOAuthClient( + client_id: string, + updates: { + name?: string; + description?: string; + redirect_uris?: string[]; + allowed_scopes?: string[]; + grant_types?: string[]; + } +): boolean { + const client = getOAuthClientByClientId(client_id); + if (!client) return false; + + const fields: string[] = []; + const values: any[] = []; + + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.description !== undefined) { + fields.push('description = ?'); + values.push(updates.description); + } + if (updates.redirect_uris !== undefined) { + fields.push('redirect_uris = ?'); + values.push(serializeJsonField(updates.redirect_uris)); + } + if (updates.allowed_scopes !== undefined) { + fields.push('allowed_scopes = ?'); + values.push(serializeJsonField(updates.allowed_scopes)); + } + if (updates.grant_types !== undefined) { + fields.push('grant_types = ?'); + values.push(serializeJsonField(updates.grant_types)); + } + + if (fields.length === 0) return false; + + fields.push("updated_at = datetime('now')"); + values.push(client_id); + + const sql = `UPDATE oauth_clients SET ${fields.join(', ')} WHERE client_id = ?`; + db.prepare(sql).run(...values); + return true; +} + +export function deleteOAuthClient(client_id: string): boolean { + const result = getStmt('DELETE FROM oauth_clients WHERE client_id = ?').run(client_id); + return result.changes > 0; +} + +// ============================================================================ +// Authorization Code Operations +// ============================================================================ + +export function createAuthorizationCode( + client_id: string, + user_id: number, + redirect_uri: string, + scopes: string[], + code_challenge?: string, + code_challenge_method?: 'S256' | 'plain', + nonce?: string, + expiresInSeconds = 600 // 10 minutes default +): OAuthAuthorizationCode { + const code = crypto.randomUUID().replace(/-/g, ''); + const expires_at = new Date(Date.now() + expiresInSeconds * 1000); + + const stmt = getStmt(` + INSERT INTO oauth_authorization_codes (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, nonce, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + code, + client_id, + user_id, + redirect_uri, + serializeJsonField(scopes), + code_challenge || null, + code_challenge_method || null, + nonce || null, + expires_at.toISOString() + ); + + return getAuthorizationCode(code)!; +} + +export function getAuthorizationCode(code: string): OAuthAuthorizationCode | undefined { + const row = getStmt('SELECT * FROM oauth_authorization_codes WHERE code = ?').get(code) as any; + if (!row) return undefined; + + return { + ...row, + scopes: parseJsonField(row.scopes), + used: row.used === 1, + }; +} + +export function markAuthorizationCodeAsUsed(code: string): void { + getStmt('UPDATE oauth_authorization_codes SET used = 1 WHERE code = ?').run(code); +} + +export function deleteAuthorizationCode(code: string): void { + getStmt('DELETE FROM oauth_authorization_codes WHERE code = ?').run(code); +} + +export function cleanupExpiredAuthorizationCodes(): void { + getStmt("DELETE FROM oauth_authorization_codes WHERE expires_at < datetime('now')").run(); +} + +// ============================================================================ +// Access Token Operations +// ============================================================================ + +export function createAccessToken( + client_id: string, + user_id: number | null, + scopes: string[], + expiresInSeconds = 3600 // 1 hour default +): OAuthAccessToken { + const token = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); + const expires_at = new Date(Date.now() + expiresInSeconds * 1000); + + const stmt = getStmt(` + INSERT INTO oauth_access_tokens (token, client_id, user_id, scopes, expires_at) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run( + token, + client_id, + user_id, + serializeJsonField(scopes), + expires_at.toISOString() + ); + + return getAccessToken(token)!; +} + +export function getAccessToken(token: string): OAuthAccessToken | undefined { + const row = getStmt('SELECT * FROM oauth_access_tokens WHERE token = ? AND revoked = 0').get(token) as any; + if (!row) return undefined; + + // Check if expired + if (new Date(row.expires_at) < new Date()) { + return undefined; + } + + return { + ...row, + scopes: parseJsonField(row.scopes), + revoked: row.revoked === 1, + }; +} + +export function revokeAccessToken(token: string): boolean { + const result = getStmt('UPDATE oauth_access_tokens SET revoked = 1 WHERE token = ?').run(token); + return result.changes > 0; +} + +export function cleanupExpiredAccessTokens(): void { + getStmt("DELETE FROM oauth_access_tokens WHERE expires_at < datetime('now', '-7 days')").run(); +} + +// ============================================================================ +// Refresh Token Operations +// ============================================================================ + +export function createRefreshToken( + access_token_id: number, + client_id: string, + user_id: number, + scopes: string[], + expiresInSeconds = 2592000 // 30 days default +): OAuthRefreshToken { + const token = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); + const expires_at = new Date(Date.now() + expiresInSeconds * 1000); + + const stmt = getStmt(` + INSERT INTO oauth_refresh_tokens (token, access_token_id, client_id, user_id, scopes, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + stmt.run( + token, + access_token_id, + client_id, + user_id, + serializeJsonField(scopes), + expires_at.toISOString() + ); + + return getRefreshToken(token)!; +} + +export function getRefreshToken(token: string): OAuthRefreshToken | undefined { + const row = getStmt('SELECT * FROM oauth_refresh_tokens WHERE token = ? AND revoked = 0').get(token) as any; + if (!row) return undefined; + + // Check if expired + if (new Date(row.expires_at) < new Date()) { + return undefined; + } + + return { + ...row, + scopes: parseJsonField(row.scopes), + revoked: row.revoked === 1, + }; +} + +export function revokeRefreshToken(token: string): boolean { + const result = getStmt('UPDATE oauth_refresh_tokens SET revoked = 1 WHERE token = ?').run(token); + return result.changes > 0; +} + +export function cleanupExpiredRefreshTokens(): void { + getStmt("DELETE FROM oauth_refresh_tokens WHERE expires_at < datetime('now', '-7 days')").run(); +} + +// ============================================================================ +// User Consent Operations +// ============================================================================ + +export function saveUserConsent( + user_id: number, + client_id: string, + scopes: string[] +): OAuthUserConsent { + const stmt = getStmt(` + INSERT INTO oauth_user_consents (user_id, client_id, scopes, granted_at, updated_at) + VALUES (?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(user_id, client_id) DO UPDATE SET + scopes = excluded.scopes, + updated_at = datetime('now') + `); + + stmt.run(user_id, client_id, serializeJsonField(scopes)); + return getUserConsent(user_id, client_id)!; +} + +export function getUserConsent(user_id: number, client_id: string): OAuthUserConsent | undefined { + const row = getStmt('SELECT * FROM oauth_user_consents WHERE user_id = ? AND client_id = ?') + .get(user_id, client_id) as any; + if (!row) return undefined; + + return { + ...row, + scopes: parseJsonField(row.scopes), + }; +} + +export function listUserConsents(user_id: number): OAuthUserConsent[] { + const rows = getStmt('SELECT * FROM oauth_user_consents WHERE user_id = ? ORDER BY updated_at DESC') + .all(user_id) as any[]; + return rows.map(row => ({ + ...row, + scopes: parseJsonField(row.scopes), + })); +} + +export function revokeUserConsent(user_id: number, client_id: string): boolean { + const result = getStmt('DELETE FROM oauth_user_consents WHERE user_id = ? AND client_id = ?') + .run(user_id, client_id); + return result.changes > 0; +} + +// ============================================================================ +// Cleanup Operations +// ============================================================================ + +export function cleanupExpiredOAuthData(): void { + cleanupExpiredAuthorizationCodes(); + cleanupExpiredAccessTokens(); + cleanupExpiredRefreshTokens(); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index ef377ea..d3c0db3 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -64,6 +64,70 @@ try { const schema = fs.readFileSync(schemaPath, 'utf8'); db.exec(schema); + + // Auto-migration: Check for new columns in existing tables + try { + const userColumns = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; + + // Add 'tags' column if missing + if (!userColumns.some(c => c.name === 'tags')) { + console.log('Migrating database: Adding tags column to users table...'); + db.prepare('ALTER TABLE users ADD COLUMN tags TEXT').run(); + } + + // Add 'email' column if missing (legacy support) + if (!userColumns.some(c => c.name === 'email')) { + console.log('Migrating database: Adding email column to users table...'); + db.prepare('ALTER TABLE users ADD COLUMN email TEXT').run(); + } + + } catch (migErr) { + console.error('Auto-migration error:', migErr); + } + + // Also load OAuth2 schema + const oauthPossiblePaths = [ + path.join(process.cwd(), 'src', 'lib', 'schema_oauth.sql'), + path.join(process.cwd(), 'schema_oauth.sql'), + path.join(__dirname, 'schema_oauth.sql'), + ]; + + let oauthSchemaPath: string | null = null; + for (const testPath of oauthPossiblePaths) { + if (fs.existsSync(testPath)) { + oauthSchemaPath = testPath; + break; + } + } + + if (oauthSchemaPath) { + const oauthSchema = fs.readFileSync(oauthSchemaPath, 'utf8'); + db.exec(oauthSchema); + } else { + console.warn('OAuth2 schema file not found - OAuth features will not be available'); + } + + // Load Federated Auth schema + const federatedPossiblePaths = [ + path.join(process.cwd(), 'src', 'lib', 'schema_federated.sql'), + path.join(process.cwd(), 'schema_federated.sql'), + path.join(__dirname, 'schema_federated.sql'), + ]; + + let federatedSchemaPath: string | null = null; + for (const testPath of federatedPossiblePaths) { + if (fs.existsSync(testPath)) { + federatedSchemaPath = testPath; + break; + } + } + + if (federatedSchemaPath) { + const fedSchema = fs.readFileSync(federatedSchemaPath, 'utf8'); + db.exec(fedSchema); + } else { + console.warn('Federated Auth schema file not found'); + } } catch (error) { console.error('Failed to initialize database schema:', error); // Re-throw in production to fail fast, but allow dev to continue @@ -76,6 +140,8 @@ export interface User { id: number; username: string; password_hash: string; + email?: string; + tags?: string[]; created_at: string; } @@ -86,19 +152,31 @@ export interface Session { created_at: string; } +// Helper to parse user tags +function parseUser(user: any): User | undefined { + if (!user) return undefined; + return { + ...user, + tags: user.tags ? user.tags.split(',') : [] + }; +} + // User operations export function getUserByUsername(username: string): User | undefined { - return getStmt('SELECT * FROM users WHERE username = ?').get(username) as User | undefined; + const user = getStmt('SELECT * FROM users WHERE username = ?').get(username); + return parseUser(user); } export function getUserById(id: number): User | undefined { - return getStmt('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; + const user = getStmt('SELECT * FROM users WHERE id = ?').get(id); + return parseUser(user); } -export function createUser(username: string, passwordHash: string): User { +export function createUser(username: string, passwordHash: string, email?: string, tags?: string[]): User { // Use INSERT OR IGNORE to prevent race conditions - const stmt = getStmt('INSERT INTO users (username, password_hash) VALUES (?, ?)'); - const result = stmt.run(username, passwordHash); + const tagsStr = tags ? tags.join(',') : null; + const stmt = getStmt('INSERT INTO users (username, password_hash, email, tags) VALUES (?, ?, ?, ?)'); + const result = stmt.run(username, passwordHash, email || null, tagsStr); // Check if insert was successful (SQLite returns changes > 0) if (result.changes === 0) { @@ -118,12 +196,20 @@ export function updateUserPassword(userId: number, passwordHash: string): void { } export function getAllUsers(): User[] { - return getStmt('SELECT id, username, password_hash, created_at FROM users').all() as User[]; + const users = getStmt('SELECT id, username, password_hash, email, tags, created_at FROM users').all(); + return users.map((u: any) => ({ + ...u, + tags: u.tags ? u.tags.split(',') : [] + })); } // Safe version that doesn't return password hashes export function getAllUsersSafe(): Omit[] { - return getStmt('SELECT id, username, created_at FROM users').all() as Omit[]; + const users = getStmt('SELECT id, username, email, tags, created_at FROM users').all(); + return users.map((u: any) => ({ + ...u, + tags: u.tags ? u.tags.split(',') : [] + })); } export function deleteUser(id: number): void { diff --git a/src/lib/oauth/oidc-tokens.ts b/src/lib/oauth/oidc-tokens.ts new file mode 100644 index 0000000..1c86477 --- /dev/null +++ b/src/lib/oauth/oidc-tokens.ts @@ -0,0 +1,95 @@ +/** + * Enhanced OAuth2 token generation with OIDC/JWT support + * + * This file extends the OAuth2 provider to support JWT tokens and ID tokens + */ + +import { generateIDToken, generateAccessTokenJWT } from '../oidc/jwt'; +import { getUserById } from '../db'; +import type { TokenResult } from './provider'; +import type { OAuthClient } from './types'; + +/** + * Generate token response with JWT tokens (OIDC compatible) + * Used for authorization_code grant + */ +export function generateOIDCTokenResponse( + client: OAuthClient, + userId: number, + scopes: string[], + expiresIn: number, + refreshToken?: string, + nonce?: string, + issuer?: string +): TokenResult { + // Get user information for ID token + const user = getUserById(userId); + if (!user) { + return { + success: false, + error: 'server_error', + error_description: 'User not found', + }; + } + + // Generate JWT access token + const accessToken = generateAccessTokenJWT( + userId, + client.client_id, + scopes, + expiresIn, + issuer + ); + + // Generate ID token if openid scope is requested + let idToken: string | undefined; + if (scopes.includes('openid')) { + // Include email in ID token if email scope is present + const email = scopes.includes('email') ? user.email : undefined; + + idToken = generateIDToken( + userId, + user.username, + client.client_id, + email, + nonce, + expiresIn, + issuer + ); + } + + return { + success: true, + access_token: accessToken, + token_type: 'Bearer', + expires_in: expiresIn, + id_token: idToken, + refresh_token: refreshToken, + scope: scopes.join(' '), + }; +} + +/** + * Generate token response for client_credentials grant + */ +export function generateClientCredentialsTokenResponse( + client: OAuthClient, + scopes: string[], + expiresIn: number +): TokenResult { + // Generate JWT access token (no user ID for client credentials) + const accessToken = generateAccessTokenJWT( + null, + client.client_id, + scopes, + expiresIn + ); + + return { + success: true, + access_token: accessToken, + token_type: 'Bearer', + expires_in: expiresIn, + scope: scopes.join(' '), + }; +} diff --git a/src/lib/oauth/provider.ts b/src/lib/oauth/provider.ts new file mode 100644 index 0000000..08bbc83 --- /dev/null +++ b/src/lib/oauth/provider.ts @@ -0,0 +1,615 @@ +import crypto from 'crypto'; +import { + getOAuthClientByClientId, + createAuthorizationCode, + getAuthorizationCode, + markAuthorizationCodeAsUsed, + createAccessToken, + createRefreshToken, + getAccessToken, + getRefreshToken, + revokeAccessToken, + revokeRefreshToken, + saveUserConsent, + getUserConsent, +} from '../db-oauth'; +import type { OAuthClient } from './types'; + +// Constants +const DEFAULT_TOKEN_EXPIRY = parseInt(process.env.OAUTH_TOKEN_EXPIRY || '3600'); // 1 hour +const DEFAULT_CODE_EXPIRY = parseInt(process.env.OAUTH_CODE_EXPIRY || '600'); // 10 minutes +const DEFAULT_REFRESH_TOKEN_EXPIRY = 30 * 24 * 60 * 60; // 30 days + +// ============================================================================ +// PKCE (Proof Key for Code Exchange) Support +// ============================================================================ + +/** + * Verify PKCE code verifier against code challenge + * Supports both S256 and plain methods + */ +export function verifyPKCE( + verifier: string, + challenge: string, + method: 'S256' | 'plain' +): boolean { + if (method === 'plain') { + return verifier === challenge; + } + + // S256: BASE64URL(SHA256(code_verifier)) + const hash = crypto.createHash('sha256').update(verifier).digest(); + const computed = base64UrlEncode(hash); + return computed === challenge; +} + +/** + * Base64 URL encoding (without padding) + */ +function base64UrlEncode(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +// ============================================================================ +// Validation Functions +// ============================================================================ + +/** + * Validate redirect URI against client's registered URIs + */ +export function validateRedirectUri(client: OAuthClient, redirect_uri: string): boolean { + return client.redirect_uris.includes(redirect_uri); +} + +/** + * Validate requested scopes against client's allowed scopes + */ +export function validateScopes(client: OAuthClient, requested_scopes: string[]): boolean { + return requested_scopes.every(scope => client.allowed_scopes.includes(scope)); +} + +/** + * Validate grant type is allowed for client + */ +export function validateGrantType(client: OAuthClient, grant_type: string): boolean { + return client.grant_types.includes(grant_type); +} + +/** + * Parse scope string into array + */ +export function parseScopes(scope?: string): string[] { + if (!scope) return ['openid']; // Default scope + return scope.split(' ').filter(s => s.length > 0); +} + +/** + * Validate client credentials + */ +export function validateClientCredentials( + client_id: string, + client_secret: string +): OAuthClient | null { + const client = getOAuthClientByClientId(client_id); + if (!client) { + console.log(`OAuth Client lookup failed for ID: ${client_id}`); + return null; + } + + // For public clients, secret validation is skipped + if (!client.is_confidential) return client; + + // For confidential clients, validate secret + if (client.client_secret !== client_secret) { + return null; + } + + return client; +} + +// ============================================================================ +// Authorization Code Flow +// ============================================================================ + +export interface AuthorizeParams { + client_id: string; + redirect_uri: string; + response_type: string; + scope?: string; + state?: string; + code_challenge?: string; + code_challenge_method?: 'S256' | 'plain'; + nonce?: string; // OIDC nonce for replay protection +} + +export interface AuthorizeResult { + success: boolean; + error?: string; + error_description?: string; + client?: OAuthClient; + scopes?: string[]; +} + +/** + * Validate authorization request parameters + */ +export function validateAuthorizeRequest(params: AuthorizeParams): AuthorizeResult { + // Validate response_type + if (params.response_type !== 'code') { + return { + success: false, + error: 'unsupported_response_type', + error_description: 'Only "code" response type is supported', + }; + } + + // Validate client + const client = getOAuthClientByClientId(params.client_id); + if (!client) { + return { + success: false, + error: 'invalid_client', + error_description: 'Client not found', + }; + } + + // Validate redirect URI + if (!validateRedirectUri(client, params.redirect_uri)) { + return { + success: false, + error: 'invalid_request', + error_description: 'Invalid redirect_uri', + }; + } + + // Validate scopes + const scopes = parseScopes(params.scope); + if (!validateScopes(client, scopes)) { + return { + success: false, + error: 'invalid_scope', + error_description: 'Requested scope is not allowed for this client', + }; + } + + // Validate authorization_code grant is allowed + if (!validateGrantType(client, 'authorization_code')) { + return { + success: false, + error: 'unauthorized_client', + error_description: 'Client is not authorized for authorization code flow', + }; + } + + // PKCE validation (S256 is recommended, but both are supported) + if (params.code_challenge) { + if (!params.code_challenge_method) { + return { + success: false, + error: 'invalid_request', + error_description: 'code_challenge_method is required when using PKCE', + }; + } + if (params.code_challenge_method !== 'S256' && params.code_challenge_method !== 'plain') { + return { + success: false, + error: 'invalid_request', + error_description: 'Unsupported code_challenge_method', + }; + } + } + + return { + success: true, + client, + scopes, + }; +} + +/** + * Generate authorization code after user consent + */ +export function generateAuthorizationCode( + client_id: string, + user_id: number, + redirect_uri: string, + scopes: string[], + code_challenge?: string, + code_challenge_method?: 'S256' | 'plain', + nonce?: string +): string { + const authCode = createAuthorizationCode( + client_id, + user_id, + redirect_uri, + scopes, + code_challenge, + code_challenge_method, + nonce, + DEFAULT_CODE_EXPIRY + ); + + // Save user consent for future requests + saveUserConsent(user_id, client_id, scopes); + + return authCode.code; +} + +// ============================================================================ +// Token Exchange +// ============================================================================ + +export interface TokenParams { + grant_type: string; + code?: string; + redirect_uri?: string; + client_id: string; + client_secret: string; + code_verifier?: string; + refresh_token?: string; + scope?: string; + issuer?: string; // Dynamic issuer URL +} + +export interface TokenResult { + success: boolean; + error?: string; + error_description?: string; + access_token?: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + id_token?: string; // For OIDC +} + +/** + * Handle token request (authorization_code, client_credentials, refresh_token grants) + */ +export function handleTokenRequest(params: TokenParams): TokenResult { + // Validate client credentials + const client = validateClientCredentials(params.client_id, params.client_secret); + if (!client) { + return { + success: false, + error: 'invalid_client', + error_description: 'Invalid client credentials', + }; + } + + // Validate grant type is allowed + if (!validateGrantType(client, params.grant_type)) { + return { + success: false, + error: 'unauthorized_client', + error_description: 'Client is not authorized for this grant type', + }; + } + + // Handle different grant types + switch (params.grant_type) { + case 'authorization_code': + return handleAuthorizationCodeGrant(client, params); + case 'client_credentials': + return handleClientCredentialsGrant(client, params); + case 'refresh_token': + return handleRefreshTokenGrant(client, params); + default: + return { + success: false, + error: 'unsupported_grant_type', + error_description: `Grant type "${params.grant_type}" is not supported`, + }; + } +} + +/** + * Handle authorization_code grant + */ +function handleAuthorizationCodeGrant(client: OAuthClient, params: TokenParams): TokenResult { + if (!params.code) { + return { + success: false, + error: 'invalid_request', + error_description: 'Missing authorization code', + }; + } + + if (!params.redirect_uri) { + return { + success: false, + error: 'invalid_request', + error_description: 'Missing redirect_uri', + }; + } + + // Get authorization code + const authCode = getAuthorizationCode(params.code); + if (!authCode) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Invalid or expired authorization code', + }; + } + + // Validate authorization code + if (authCode.used) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Authorization code has already been used', + }; + } + + if (authCode.client_id !== client.client_id) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Authorization code was issued to a different client', + }; + } + + if (authCode.redirect_uri !== params.redirect_uri) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Redirect URI mismatch', + }; + } + + // Check if expired + if (new Date(authCode.expires_at).getTime() < Date.now()) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Authorization code has expired', + }; + } + + // Validate PKCE if code challenge was provided + if (authCode.code_challenge) { + if (!params.code_verifier) { + return { + success: false, + error: 'invalid_request', + error_description: 'Missing code_verifier for PKCE', + }; + } + + const valid = verifyPKCE( + params.code_verifier, + authCode.code_challenge, + authCode.code_challenge_method || 'S256' + ); + + if (!valid) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Invalid PKCE code_verifier', + }; + } + } + + // Mark code as used + markAuthorizationCodeAsUsed(params.code); + + // Check if openid scope is present for OIDC + const isOIDC = authCode.scopes.includes('openid'); + + if (isOIDC) { + // Use JWT tokens for OIDC flow + const { generateOIDCTokenResponse } = require('./oidc-tokens'); + const { getUserById } = require('../db'); + + const user = getUserById(authCode.user_id); + if (!user) { + return { + success: false, + error: 'server_error', + error_description: 'User not found', + }; + } + + // Generate refresh token if offline_access scope is granted + let refreshToken: string | undefined; + if (authCode.scopes.includes('offline_access') && validateGrantType(client, 'refresh_token')) { + // For now, create regular refresh token (can be JWT later) + const refreshTokenObj = createRefreshToken( + -1, // Placeholder, will need access token ID + client.client_id, + authCode.user_id, + authCode.scopes, + DEFAULT_REFRESH_TOKEN_EXPIRY + ); + refreshToken = refreshTokenObj.token; + } + + return generateOIDCTokenResponse( + client, + authCode.user_id, + authCode.scopes, + DEFAULT_TOKEN_EXPIRY, + refreshToken, + authCode.nonce, + params.issuer // Pass dynamic issuer + ); + } + + // Fallback: Use regular bearer tokens for non-OIDC flows + const accessToken = createAccessToken( + client.client_id, + authCode.user_id, + authCode.scopes, + DEFAULT_TOKEN_EXPIRY + ); + + // Generate refresh token if offline_access scope is granted + let refreshToken: string | undefined; + if (authCode.scopes.includes('offline_access') && validateGrantType(client, 'refresh_token')) { + const refreshTokenObj = createRefreshToken( + accessToken.id, + client.client_id, + authCode.user_id, + authCode.scopes, + DEFAULT_REFRESH_TOKEN_EXPIRY + ); + refreshToken = refreshTokenObj.token; + } + + return { + success: true, + access_token: accessToken.token, + token_type: 'Bearer', + expires_in: DEFAULT_TOKEN_EXPIRY, + refresh_token: refreshToken, + scope: authCode.scopes.join(' '), + }; +} + +/** + * Handle client_credentials grant + */ +function handleClientCredentialsGrant(client: OAuthClient, params: TokenParams): TokenResult { + const scopes = parseScopes(params.scope); + + // Validate scopes + if (!validateScopes(client, scopes)) { + return { + success: false, + error: 'invalid_scope', + error_description: 'Requested scope is not allowed for this client', + }; + } + + // Generate access token (no user_id for client credentials) + const accessToken = createAccessToken( + client.client_id, + null, + scopes, + DEFAULT_TOKEN_EXPIRY + ); + + return { + success: true, + access_token: accessToken.token, + token_type: 'Bearer', + expires_in: DEFAULT_TOKEN_EXPIRY, + scope: scopes.join(' '), + }; +} + +/** + * Handle refresh_token grant + */ +function handleRefreshTokenGrant(client: OAuthClient, params: TokenParams): TokenResult { + if (!params.refresh_token) { + return { + success: false, + error: 'invalid_request', + error_description: 'Missing refresh_token', + }; + } + + // Get refresh token + const refreshTokenObj = getRefreshToken(params.refresh_token); + if (!refreshTokenObj) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Invalid or expired refresh token', + }; + } + + // Validate refresh token belongs to client + if (refreshTokenObj.client_id !== client.client_id) { + return { + success: false, + error: 'invalid_grant', + error_description: 'Refresh token was issued to a different client', + }; + } + + // Generate new access token + const accessToken = createAccessToken( + client.client_id, + refreshTokenObj.user_id, + refreshTokenObj.scopes, + DEFAULT_TOKEN_EXPIRY + ); + + // Optionally generate new refresh token (token rotation) + // For now, we'll keep the same refresh token + + return { + success: true, + access_token: accessToken.token, + token_type: 'Bearer', + expires_in: DEFAULT_TOKEN_EXPIRY, + refresh_token: refreshTokenObj.token, + scope: refreshTokenObj.scopes.join(' '), + }; +} + +// ============================================================================ +// Token Introspection +// ============================================================================ + +export interface IntrospectResult { + active: boolean; + scope?: string; + client_id?: string; + username?: string; + token_type?: 'Bearer'; + exp?: number; + iat?: number; + sub?: string; +} + +/** + * Introspect access token + */ +export function introspectToken(token: string): IntrospectResult { + const accessToken = getAccessToken(token); + if (!accessToken) { + return { active: false }; + } + + const expiresAt = new Date(accessToken.expires_at); + const createdAt = new Date(accessToken.created_at); + + return { + active: true, + scope: accessToken.scopes.join(' '), + client_id: accessToken.client_id, + token_type: 'Bearer', + exp: Math.floor(expiresAt.getTime() / 1000), + iat: Math.floor(createdAt.getTime() / 1000), + sub: accessToken.user_id?.toString(), + }; +} + +// ============================================================================ +// Token Revocation +// ============================================================================ + +/** + * Revoke access or refresh token + */ +export function revokeToken(token: string, token_type_hint?: 'access_token' | 'refresh_token'): boolean { + if (token_type_hint === 'refresh_token') { + return revokeRefreshToken(token); + } + + // Try access token first, then refresh token + if (revokeAccessToken(token)) { + return true; + } + + return revokeRefreshToken(token); +} diff --git a/src/lib/oauth/types.ts b/src/lib/oauth/types.ts new file mode 100644 index 0000000..79d8400 --- /dev/null +++ b/src/lib/oauth/types.ts @@ -0,0 +1,142 @@ +// OAuth2/OIDC Type Definitions + +export interface OAuthClient { + id: number; + client_id: string; + client_secret: string; + name: string; + description?: string; + redirect_uris: string[]; // Stored as JSON in DB + allowed_scopes: string[]; // Stored as JSON in DB + grant_types: string[]; // Stored as JSON in DB + is_confidential: boolean; + created_at: string; + updated_at: string; +} + +export interface OAuthAuthorizationCode { + id: number; + code: string; + client_id: string; + user_id: number; + redirect_uri: string; + scopes: string[]; // Stored as JSON in DB + code_challenge?: string; + code_challenge_method?: 'S256' | 'plain'; + nonce?: string; // OIDC nonce for ID token + expires_at: string; + created_at: string; + used: boolean; +} + +export interface OAuthAccessToken { + id: number; + token: string; + client_id: string; + user_id: number | null; // null for client_credentials grant + scopes: string[]; // Stored as JSON in DB + expires_at: string; + created_at: string; + revoked: boolean; +} + +export interface OAuthRefreshToken { + id: number; + token: string; + access_token_id: number; + client_id: string; + user_id: number; + scopes: string[]; // Stored as JSON in DB + expires_at: string; + created_at: string; + revoked: boolean; +} + +export interface OAuthUserConsent { + id: number; + user_id: number; + client_id: string; + scopes: string[]; // Stored as JSON in DB + granted_at: string; + updated_at: string; +} + +// Request/Response types for OAuth2 flows + +export interface AuthorizeRequest { + response_type: 'code'; + client_id: string; + redirect_uri: string; + scope?: string; + state?: string; + code_challenge?: string; + code_challenge_method?: 'S256' | 'plain'; +} + +export interface TokenRequest { + grant_type: 'authorization_code' | 'client_credentials' | 'refresh_token'; + code?: string; // Required for authorization_code + redirect_uri?: string; // Required for authorization_code + client_id: string; + client_secret: string; + code_verifier?: string; // Required for PKCE + refresh_token?: string; // Required for refresh_token grant + scope?: string; +} + +export interface TokenResponse { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + refresh_token?: string; + scope: string; +} + +export interface UserInfoResponse { + sub: string; // User ID + preferred_username?: string; + email?: string; + name?: string; +} + +export interface IntrospectRequest { + token: string; + token_type_hint?: 'access_token' | 'refresh_token'; +} + +export interface IntrospectResponse { + active: boolean; + scope?: string; + client_id?: string; + username?: string; + token_type?: 'Bearer'; + exp?: number; + iat?: number; + sub?: string; +} + +export interface RevokeRequest { + token: string; + token_type_hint?: 'access_token' | 'refresh_token'; +} + +// Client creation request +export interface CreateOAuthClientRequest { + name: string; + description?: string; + redirect_uris: string[]; + allowed_scopes?: string[]; + grant_types?: string[]; + is_confidential?: boolean; +} + +// Safe client response (without secret for listing) +export type OAuthClientSafe = Omit; + +// Supported scopes +export const SUPPORTED_SCOPES = ['openid', 'profile', 'username', 'email'] as const; +export type SupportedScope = typeof SUPPORTED_SCOPES[number]; + +// Supported grant types +export const SUPPORTED_GRANT_TYPES = ['authorization_code', 'client_credentials', 'refresh_token'] as const; +export type SupportedGrantType = typeof SUPPORTED_GRANT_TYPES[number]; diff --git a/src/lib/oidc/client-discovery.ts b/src/lib/oidc/client-discovery.ts new file mode 100644 index 0000000..3b59282 --- /dev/null +++ b/src/lib/oidc/client-discovery.ts @@ -0,0 +1,37 @@ +export interface OIDCProviderMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; +} + +export async function fetchOIDCConfiguration(issuer: string): Promise { + // Ensure issuer has no trailing slash for the suffix append, but standard says base + /.well-known + const baseUrl = issuer.replace(/\/$/, ''); + const discoveryUrl = `${baseUrl}/.well-known/openid-configuration`; + + try { + const res = await fetch(discoveryUrl, { next: { revalidate: 3600 } }); // Cache for 1 hour + if (!res.ok) { + throw new Error(`Failed to fetch OIDC config: ${res.statusText}`); + } + + const data = await res.json(); + + if (!data.authorization_endpoint || !data.token_endpoint || !data.jwks_uri) { + throw new Error('Invalid OIDC discovery document: Missing required endpoints'); + } + + return { + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + jwks_uri: data.jwks_uri + }; + } catch (error) { + console.error(`OIDC Discovery failed for ${issuer}:`, error); + throw error; + } +} diff --git a/src/lib/oidc/discovery.ts b/src/lib/oidc/discovery.ts new file mode 100644 index 0000000..9065dfc --- /dev/null +++ b/src/lib/oidc/discovery.ts @@ -0,0 +1,80 @@ +/** + * OIDC Discovery Configuration Generator + * Generates OpenID Connect Discovery metadata + */ + +export interface OIDCConfiguration { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + jwks_uri: string; + response_types_supported: string[]; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + scopes_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + claims_supported: string[]; + grant_types_supported: string[]; + code_challenge_methods_supported: string[]; +} + +/** + * Generate OIDC Discovery configuration + */ +export function getOIDCConfiguration(baseUrl?: string): OIDCConfiguration { + const issuer = baseUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + + return { + issuer, + authorization_endpoint: `${issuer}/api/oauth/authorize`, + token_endpoint: `${issuer}/api/oauth/token`, + userinfo_endpoint: `${issuer}/api/oauth/userinfo`, + jwks_uri: `${issuer}/api/.well-known/jwks.json`, + + response_types_supported: [ + 'code', + 'id_token', + 'token id_token', + 'code id_token', + 'code token', + 'code token id_token', + ], + + subject_types_supported: ['public'], + + id_token_signing_alg_values_supported: ['RS256'], + + scopes_supported: [ + 'openid', + 'profile', + 'email', + 'offline_access', + ], + + token_endpoint_auth_methods_supported: [ + 'client_secret_post', + 'client_secret_basic', + ], + + claims_supported: [ + 'sub', + 'iss', + 'aud', + 'exp', + 'iat', + 'name', + 'preferred_username', + 'email', + 'email_verified', + ], + + grant_types_supported: [ + 'authorization_code', + 'refresh_token', + 'client_credentials', + ], + + code_challenge_methods_supported: ['S256', 'plain'], + }; +} diff --git a/src/lib/oidc/jwt.ts b/src/lib/oidc/jwt.ts new file mode 100644 index 0000000..6b69a38 --- /dev/null +++ b/src/lib/oidc/jwt.ts @@ -0,0 +1,214 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +// Key storage paths +const DATA_DIR = process.env.DATA_DIR || './data'; +const KEYS_DIR = path.join(DATA_DIR, 'keys'); +const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'jwt-private.pem'); +const PUBLIC_KEY_PATH = path.join(KEYS_DIR, 'jwt-public.pem'); + +// Ensure keys directory exists +if (!fs.existsSync(KEYS_DIR)) { + fs.mkdirSync(KEYS_DIR, { recursive: true }); +} + +// Key pair cache +let cachedKeyPair: { privateKey: string; publicKey: string } | null = null; + +/** + * Generate RSA key pair for JWT signing + */ +export function generateKeyPair(): { privateKey: string; publicKey: string } { + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + return { privateKey, publicKey }; +} + +/** + * Get or generate JWT signing keys + */ +export function getJWTKeys(): { privateKey: string; publicKey: string } { + // Return cached keys if available + if (cachedKeyPair) { + return cachedKeyPair; + } + + // Check if keys exist on disk + if (fs.existsSync(PRIVATE_KEY_PATH) && fs.existsSync(PUBLIC_KEY_PATH)) { + cachedKeyPair = { + privateKey: fs.readFileSync(PRIVATE_KEY_PATH, 'utf8'), + publicKey: fs.readFileSync(PUBLIC_KEY_PATH, 'utf8'), + }; + return cachedKeyPair; + } + + // Generate new keys + console.log('Generating new JWT signing keys...'); + const keyPair = generateKeyPair(); + + // Save to disk + fs.writeFileSync(PRIVATE_KEY_PATH, keyPair.privateKey, { mode: 0o600 }); // Read/write for owner only + fs.writeFileSync(PUBLIC_KEY_PATH, keyPair.publicKey); + + cachedKeyPair = keyPair; + return keyPair; +} + +/** + * Get public key ID (kid) for JWKS + */ +export function getKeyId(): string { + const { publicKey } = getJWTKeys(); + // Generate stable key ID from public key hash + const hash = crypto.createHash('sha256').update(publicKey).digest('hex'); + return hash.substring(0, 16); +} + +// JWT Token Types +export interface JWTPayload { + sub: string; // Subject (user ID) + iss: string; // Issuer + aud: string | string[]; // Audience (client ID) + exp: number; // Expiration time + iat: number; // Issued at + nonce?: string; // Nonce for replay protection +} + +export interface IDTokenPayload extends JWTPayload { + preferred_username?: string; + name?: string; + email?: string; + email_verified?: boolean; +} + +export interface AccessTokenPayload extends JWTPayload { + scope: string; + client_id: string; +} + +/** + * Generate ID Token (for OIDC) + */ +export function generateIDToken( + userId: number, + username: string, + clientId: string, + email?: string, + nonce?: string, + expiresIn = 3600, + issuerUrl?: string +): string { + const { privateKey } = getJWTKeys(); + const now = Math.floor(Date.now() / 1000); + const issuer = issuerUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + + const payload: IDTokenPayload = { + sub: userId.toString(), + iss: issuer, + aud: clientId, + exp: now + expiresIn, + iat: now, + preferred_username: username, + name: username, + ...(email && { email, email_verified: true }), + // Add nonce if provided (for replay protection) + ...(nonce && { nonce }), + }; + + return jwt.sign(payload, privateKey, { + algorithm: 'RS256', + keyid: getKeyId(), + }); +} + +/** + * Generate Access Token as JWT + */ +export function generateAccessTokenJWT( + userId: number | null, + clientId: string, + scopes: string[], + expiresIn = 3600, + issuerUrl?: string +): string { + const { privateKey } = getJWTKeys(); + const now = Math.floor(Date.now() / 1000); + const issuer = issuerUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + + const payload: AccessTokenPayload = { + sub: userId?.toString() || clientId, // For client_credentials, sub is client_id + iss: issuer, + aud: clientId, + exp: now + expiresIn, + iat: now, + scope: scopes.join(' '), + client_id: clientId, + }; + + return jwt.sign(payload, privateKey, { + algorithm: 'RS256', + keyid: getKeyId(), + }); +} + +/** + * Verify JWT token + */ +export function verifyJWT(token: string): JWTPayload { + const { publicKey } = getJWTKeys(); + + try { + const decoded = jwt.verify(token, publicKey, { + algorithms: ['RS256'], + }) as JWTPayload; + + return decoded; + } catch (error) { + throw new Error(`Invalid JWT: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Decode JWT without verification (for debugging) + */ +export function decodeJWT(token: string): JWTPayload | null { + try { + return jwt.decode(token) as JWTPayload; + } catch { + return null; + } +} + +/** + * Get JWKS (JSON Web Key Set) for public key distribution + */ +export function getJWKS(): object { + const { publicKey } = getJWTKeys(); + + // Convert PEM to JWK format + const keyObject = crypto.createPublicKey(publicKey); + const jwk = keyObject.export({ format: 'jwk' }); + + return { + keys: [ + { + ...jwk, + kid: getKeyId(), + use: 'sig', + alg: 'RS256', + }, + ], + }; +} diff --git a/src/lib/proxy/auth-proxy.ts b/src/lib/proxy/auth-proxy.ts new file mode 100644 index 0000000..8439169 --- /dev/null +++ b/src/lib/proxy/auth-proxy.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '../auth'; +import type { AuthInfo } from './types'; +import { getProtectedApplicationBySlug } from './db-proxy'; + +/** + * Check if user is authenticated and authorized for the application + */ +export async function checkAuth( + request: NextRequest, + appSlug: string +): Promise<{ authorized: boolean; user?: AuthInfo; error?: string }> { + // Get current user + const user = await getCurrentUser(); + + if (!user) { + return { + authorized: false, + error: 'Authentication required', + }; + } + + // Get application config + const app = getProtectedApplicationBySlug(appSlug); + if (!app) { + return { + authorized: false, + error: 'Application not found', + }; + } + + // Check if authentication is required + if (!app.require_auth) { + return { + authorized: true, + user: { + userId: user.id, + username: user.username, + }, + }; + } + + // Check if user is in allowed list + if (app.allowed_users && app.allowed_users.length > 0) { + if (!app.allowed_users.includes(user.username)) { + return { + authorized: false, + error: 'Access denied: user not authorized for this application', + }; + } + } + + return { + authorized: true, + user: { + userId: user.id, + username: user.username, + }, + }; +} + +/** + * Inject authentication headers for backend application + */ +export function injectAuthHeaders( + headers: Headers, + user: AuthInfo, + injectHeaders: boolean, + stripAuthHeader: boolean +): Headers { + const newHeaders = new Headers(headers); + + // Strip Authorization header if configured + if (stripAuthHeader) { + newHeaders.delete('authorization'); + newHeaders.delete('Authorization'); + } + + // Inject auth headers if configured + if (injectHeaders) { + newHeaders.set('X-Auth-User', user.username); + newHeaders.set('X-Auth-User-Id', user.userId.toString()); + + if (user.email) { + newHeaders.set('X-Auth-Email', user.email); + } + + // Add remote user header (common standard) + newHeaders.set('X-Remote-User', user.username); + + // Add forwarded headers + newHeaders.set('X-Forwarded-User', user.username); + } + + return newHeaders; +} + +/** + * Proxy request to backend with authentication + */ +export async function proxyRequest( + request: NextRequest, + appSlug: string, + path: string +): Promise { + // Check authentication + const authCheck = await checkAuth(request, appSlug); + + if (!authCheck.authorized) { + // Redirect to login for HTML requests + if (request.headers.get('accept')?.includes('text/html')) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('returnTo', request.url); + return NextResponse.redirect(loginUrl.toString()); + } + + // Return 401 for API requests + return NextResponse.json( + { error: authCheck.error || 'Unauthorized' }, + { status: 401 } + ); + } + + // Get application config + const app = getProtectedApplicationBySlug(appSlug)!; + + // Build backend URL + const backendUrl = new URL(path, app.backend_url); + + // Copy query parameters + request.nextUrl.searchParams.forEach((value, key) => { + backendUrl.searchParams.set(key, value); + }); + + // Prepare headers + const headers = injectAuthHeaders( + request.headers, + authCheck.user!, + app.inject_headers, + app.strip_auth_header + ); + + // Remove host header to avoid confusion + headers.delete('host'); + + try { + // Forward request to backend + const response = await fetch(backendUrl.toString(), { + method: request.method, + headers: headers, + body: request.body, + // @ts-ignore + duplex: 'half', // Required for streaming request bodies + }); + + // Create response with backend content + const responseHeaders = new Headers(response.headers); + + // Remove hop-by-hop headers + responseHeaders.delete('transfer-encoding'); + responseHeaders.delete('connection'); + responseHeaders.delete('keep-alive'); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error) { + console.error(`Proxy error for ${appSlug}:`, error); + return NextResponse.json( + { error: 'Backend service unavailable' }, + { status: 502 } + ); + } +} diff --git a/src/lib/proxy/db-proxy.ts b/src/lib/proxy/db-proxy.ts new file mode 100644 index 0000000..5de2596 --- /dev/null +++ b/src/lib/proxy/db-proxy.ts @@ -0,0 +1,167 @@ +import db from '../db'; +import type { ProtectedApplication } from './types'; + +// Helper to get or create prepared statement +const preparedStmts = new Map(); + +function getStmt(sql: string): any { + if (!preparedStmts.has(sql)) { + preparedStmts.set(sql, db.prepare(sql)); + } + return preparedStmts.get(sql)!; +} + +// Helper to parse JSON fields +function parseJsonField(data: string | null): T | null { + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } +} + +// Helper to serialize JSON fields +function serializeJsonField(data: T): string { + return JSON.stringify(data); +} + +/** + * Create Protected Application + */ +export function createProtectedApplication( + name: string, + slug: string, + backend_url: string, + require_auth = true, + allowed_users: string[] | null = null, + inject_headers = true, + strip_auth_header = true +): ProtectedApplication { + const stmt = getStmt(` + INSERT INTO protected_applications + (name, slug, backend_url, require_auth, allowed_users, inject_headers, strip_auth_header) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + name, + slug, + backend_url, + require_auth ? 1 : 0, + allowed_users ? serializeJsonField(allowed_users) : null, + inject_headers ? 1 : 0, + strip_auth_header ? 1 : 0 + ); + + return getProtectedApplicationBySlug(slug)!; +} + +/** + * Get Protected Application by slug + */ +export function getProtectedApplicationBySlug(slug: string): ProtectedApplication | undefined { + const row = getStmt('SELECT * FROM protected_applications WHERE slug = ?').get(slug) as any; + if (!row) return undefined; + + return { + ...row, + require_auth: row.require_auth === 1, + allowed_users: parseJsonField(row.allowed_users), + inject_headers: row.inject_headers === 1, + strip_auth_header: row.strip_auth_header === 1, + }; +} + +/** + * Get Protected Application by ID + */ +export function getProtectedApplicationById(id: number): ProtectedApplication | undefined { + const row = getStmt('SELECT * FROM protected_applications WHERE id = ?').get(id) as any; + if (!row) return undefined; + + return { + ...row, + require_auth: row.require_auth === 1, + allowed_users: parseJsonField(row.allowed_users), + inject_headers: row.inject_headers === 1, + strip_auth_header: row.strip_auth_header === 1, + }; +} + +/** + * List all Protected Applications + */ +export function listProtectedApplications(): ProtectedApplication[] { + const rows = getStmt('SELECT * FROM protected_applications ORDER BY created_at DESC').all() as any[]; + return rows.map(row => ({ + ...row, + require_auth: row.require_auth === 1, + allowed_users: parseJsonField(row.allowed_users), + inject_headers: row.inject_headers === 1, + strip_auth_header: row.strip_auth_header === 1, + })); +} + +/** + * Update Protected Application + */ +export function updateProtectedApplication( + slug: string, + updates: { + name?: string; + backend_url?: string; + require_auth?: boolean; + allowed_users?: string[] | null; + inject_headers?: boolean; + strip_auth_header?: boolean; + } +): boolean { + const app = getProtectedApplicationBySlug(slug); + if (!app) return false; + + const fields: string[] = []; + const values: any[] = []; + + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.backend_url !== undefined) { + fields.push('backend_url = ?'); + values.push(updates.backend_url); + } + if (updates.require_auth !== undefined) { + fields.push('require_auth = ?'); + values.push(updates.require_auth ? 1 : 0); + } + if (updates.allowed_users !== undefined) { + fields.push('allowed_users = ?'); + values.push(updates.allowed_users ? serializeJsonField(updates.allowed_users) : null); + } + if (updates.inject_headers !== undefined) { + fields.push('inject_headers = ?'); + values.push(updates.inject_headers ? 1 : 0); + } + if (updates.strip_auth_header !== undefined) { + fields.push('strip_auth_header = ?'); + values.push(updates.strip_auth_header ? 1 : 0); + } + + if (fields.length === 0) return false; + + fields.push("updated_at = datetime('now')"); + values.push(slug); + + const sql = `UPDATE protected_applications SET ${fields.join(', ')} WHERE slug = ?`; + db.prepare(sql).run(...values); + return true; +} + +/** + * Delete Protected Application + */ +export function deleteProtectedApplication(slug: string): boolean { + const result = getStmt('DELETE FROM protected_applications WHERE slug = ?').run(slug); + return result.changes > 0; +} diff --git a/src/lib/proxy/types.ts b/src/lib/proxy/types.ts new file mode 100644 index 0000000..df3c5c0 --- /dev/null +++ b/src/lib/proxy/types.ts @@ -0,0 +1,29 @@ +/** + * Authentication Proxy Types + */ + +export interface ProtectedApplication { + id: number; + name: string; + slug: string; // URL path: /proxy/{slug}/* + backend_url: string; // Target backend URL + require_auth: boolean; // Require authentication + allowed_users: string[] | null; // Whitelist of usernames, or null for all + inject_headers: boolean; // Inject X-Auth-* headers + strip_auth_header: boolean; // Strip Authorization header + created_at: string; + updated_at: string; +} + +export interface ProxyRequest { + method: string; + path: string; + headers: Record; + body?: any; +} + +export interface AuthInfo { + userId: number; + username: string; + email?: string; +} diff --git a/src/lib/saml/certificates.ts b/src/lib/saml/certificates.ts new file mode 100644 index 0000000..d037068 --- /dev/null +++ b/src/lib/saml/certificates.ts @@ -0,0 +1,163 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const DATA_DIR = process.env.DATA_DIR || './data'; +const SAML_DIR = path.join(DATA_DIR, 'saml'); +const CERT_PATH = path.join(SAML_DIR, 'cert.pem'); +const KEY_PATH = path.join(SAML_DIR, 'key.pem'); + +// Ensure SAML directory exists +if (!fs.existsSync(SAML_DIR)) { + fs.mkdirSync(SAML_DIR, { recursive: true }); +} + +// Certificate cache +let cachedCerts: { certificate: string; privateKey: string } | null = null; + +/** + * Generate self-signed X.509 certificate for SAML + */ +export function generateSAMLCertificate(): { certificate: string; privateKey: string } { + // Generate RSA key pair + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + // Create self-signed certificate + // Note: For production, you might want to use a library like node-forge + // or generate certificates manually with openssl + // For now, we'll create a simple certificate structure + + const cert = `-----BEGIN CERTIFICATE----- +${Buffer.from(publicKey).toString('base64')} +-----END CERTIFICATE-----`; + + return { + certificate: cert, + privateKey, + }; +} + +/** + * Generate proper self-signed X.509 certificate using openssl command + * This creates a valid certificate for SAML signing + */ +export function generateProperSAMLCertificate(): { certificate: string; privateKey: string } { + const execSync = require('child_process').execSync; + + try { + // Generate private key + execSync( + `openssl genrsa -out "${KEY_PATH}" 2048`, + { stdio: 'pipe' } + ); + + // Generate self-signed certificate (valid for 10 years) + execSync( + `openssl req -new -x509 -key "${KEY_PATH}" -out "${CERT_PATH}" -days 3650 ` + + `-subj "/CN=Atom SAML IdP/O=Atom/C=US"`, + { stdio: 'pipe' } + ); + + const certificate = fs.readFileSync(CERT_PATH, 'utf8'); + const privateKey = fs.readFileSync(KEY_PATH, 'utf8'); + + return { certificate, privateKey }; + } catch (error) { + console.error('Failed to generate certificate with openssl:', error); + // Fallback to basic generation + return generateSAMLCertificate(); + } +} + +/** + * Get or generate SAML signing certificate + */ +export function getSAMLCertificate(): { certificate: string; privateKey: string } { + // Return cached certificates if available + if (cachedCerts) { + return cachedCerts; + } + + // Check if certificates exist on disk + if (fs.existsSync(CERT_PATH) && fs.existsSync(KEY_PATH)) { + cachedCerts = { + certificate: fs.readFileSync(CERT_PATH, 'utf8'), + privateKey: fs.readFileSync(KEY_PATH, 'utf8'), + }; + return cachedCerts; + } + + // Generate new certificates + console.log('Generating new SAML signing certificates...'); + const certs = generateProperSAMLCertificate(); + + // Save to disk + fs.writeFileSync(CERT_PATH, certs.certificate); + fs.writeFileSync(KEY_PATH, certs.privateKey, { mode: 0o600 }); // Read/write for owner only + + cachedCerts = certs; + return certs; +} + +/** + * Get certificate fingerprint (SHA-256) + */ +export function getCertificateFingerprint(): string { + const { certificate } = getSAMLCertificate(); + + // Extract certificate content (remove headers and newlines) + const certContent = certificate + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\n/g, ''); + + // Calculate SHA-256 hash + const hash = crypto.createHash('sha256') + .update(Buffer.from(certContent, 'base64')) + .digest('hex'); + + // Format as fingerprint (XX:XX:XX...) + return hash.match(/.{1,2}/g)?.join(':').toUpperCase() || hash; +} + +/** + * Get certificate for display (without private key) + */ +export function getPublicCertificate(): string { + const { certificate } = getSAMLCertificate(); + return certificate; +} + +/** + * Upload custom certificate (admin functionality) + */ +export function uploadSAMLCertificate(certificate: string, privateKey: string): boolean { + try { + // Validate certificate format + if (!certificate.includes('BEGIN CERTIFICATE') || !privateKey.includes('BEGIN')) { + throw new Error('Invalid certificate or private key format'); + } + + // Save to disk + fs.writeFileSync(CERT_PATH, certificate); + fs.writeFileSync(KEY_PATH, privateKey, { mode: 0o600 }); + + // Clear cache + cachedCerts = { certificate, privateKey }; + + return true; + } catch (error) { + console.error('Failed to upload certificate:', error); + return false; + } +} diff --git a/src/lib/saml/db-saml.ts b/src/lib/saml/db-saml.ts new file mode 100644 index 0000000..6d747cb --- /dev/null +++ b/src/lib/saml/db-saml.ts @@ -0,0 +1,178 @@ +import db from '../db'; +import type { SAMLServiceProvider } from './types'; + +// Helper to get or create prepared statement +const preparedStmts = new Map(); + +function getStmt(sql: string): any { + if (!preparedStmts.has(sql)) { + preparedStmts.set(sql, db.prepare(sql)); + } + return preparedStmts.get(sql)!; +} + +// Helper to parse JSON fields +function parseJsonField(data: string | null): T { + if (!data) return {} as T; + try { + return JSON.parse(data); + } catch { + return {} as T; + } +} + +// Helper to serialize JSON fields +function serializeJsonField(data: T): string { + return JSON.stringify(data); +} + +/** + * Create SAML Service Provider + */ +export function createSAMLServiceProvider( + entity_id: string, + name: string, + acs_url: string, + description?: string, + metadata_url?: string, + sp_certificate?: string, + attribute_mapping: Record = {}, + sign_assertions = true, + encrypt_assertions = false +): SAMLServiceProvider { + const stmt = getStmt(` + INSERT INTO saml_service_providers + (entity_id, name, description, acs_url, metadata_url, sp_certificate, attribute_mapping, sign_assertions, encrypt_assertions) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + entity_id, + name, + description || null, + acs_url, + metadata_url || null, + sp_certificate || null, + serializeJsonField(attribute_mapping), + sign_assertions ? 1 : 0, + encrypt_assertions ? 1 : 0 + ); + + return getSAMLServiceProviderByEntityId(entity_id)!; +} + +/** + * Get SAML Service Provider by Entity ID + */ +export function getSAMLServiceProviderByEntityId(entity_id: string): SAMLServiceProvider | undefined { + const row = getStmt('SELECT * FROM saml_service_providers WHERE entity_id = ?').get(entity_id) as any; + if (!row) return undefined; + + return { + ...row, + attribute_mapping: parseJsonField(row.attribute_mapping), + sign_assertions: row.sign_assertions === 1, + encrypt_assertions: row.encrypt_assertions === 1, + }; +} + +/** + * Get SAML Service Provider by ID + */ +export function getSAMLServiceProviderById(id: number): SAMLServiceProvider | undefined { + const row = getStmt('SELECT * FROM saml_service_providers WHERE id = ?').get(id) as any; + if (!row) return undefined; + + return { + ...row, + attribute_mapping: parseJsonField(row.attribute_mapping), + sign_assertions: row.sign_assertions === 1, + encrypt_assertions: row.encrypt_assertions === 1, + }; +} + +/** + * List all SAML Service Providers + */ +export function listSAMLServiceProviders(): SAMLServiceProvider[] { + const rows = getStmt('SELECT * FROM saml_service_providers ORDER BY created_at DESC').all() as any[]; + return rows.map(row => ({ + ...row, + attribute_mapping: parseJsonField(row.attribute_mapping), + sign_assertions: row.sign_assertions === 1, + encrypt_assertions: row.encrypt_assertions === 1, + })); +} + +/** + * Update SAML Service Provider + */ +export function updateSAMLServiceProvider( + entity_id: string, + updates: { + name?: string; + description?: string; + acs_url?: string; + metadata_url?: string; + sp_certificate?: string; + attribute_mapping?: Record; + sign_assertions?: boolean; + encrypt_assertions?: boolean; + } +): boolean { + const sp = getSAMLServiceProviderByEntityId(entity_id); + if (!sp) return false; + + const fields: string[] = []; + const values: any[] = []; + + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.description !== undefined) { + fields.push('description = ?'); + values.push(updates.description); + } + if (updates.acs_url !== undefined) { + fields.push('acs_url = ?'); + values.push(updates.acs_url); + } + if (updates.metadata_url !== undefined) { + fields.push('metadata_url = ?'); + values.push(updates.metadata_url); + } + if (updates.sp_certificate !== undefined) { + fields.push('sp_certificate = ?'); + values.push(updates.sp_certificate); + } + if (updates.attribute_mapping !== undefined) { + fields.push('attribute_mapping = ?'); + values.push(serializeJsonField(updates.attribute_mapping)); + } + if (updates.sign_assertions !== undefined) { + fields.push('sign_assertions = ?'); + values.push(updates.sign_assertions ? 1 : 0); + } + if (updates.encrypt_assertions !== undefined) { + fields.push('encrypt_assertions = ?'); + values.push(updates.encrypt_assertions ? 1 : 0); + } + + if (fields.length === 0) return false; + + fields.push("updated_at = datetime('now')"); + values.push(entity_id); + + const sql = `UPDATE saml_service_providers SET ${fields.join(', ')} WHERE entity_id = ?`; + db.prepare(sql).run(...values); + return true; +} + +/** + * Delete SAML Service Provider + */ +export function deleteSAMLServiceProvider(entity_id: string): boolean { + const result = getStmt('DELETE FROM saml_service_providers WHERE entity_id = ?').run(entity_id); + return result.changes > 0; +} diff --git a/src/lib/saml/metadata.ts b/src/lib/saml/metadata.ts new file mode 100644 index 0000000..a3bf0ea --- /dev/null +++ b/src/lib/saml/metadata.ts @@ -0,0 +1,89 @@ +import { getSAMLCertificate, getPublicCertificate } from './certificates'; + +export interface SAMLMetadataConfig { + issuer: string; // Entity ID of IdP + ssoServiceUrl: string; // SSO endpoint URL + certificate: string; // Public certificate for signature verification +} + +/** + * Generate SAML 2.0 IdP Metadata (EntityDescriptor) + */ +export function generateSAMLMetadata(baseUrl?: string): string { + const issuer = baseUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + const entityId = `${issuer}/saml/metadata`; + const ssoServiceUrl = `${issuer}/api/saml/sso`; + + // Get public certificate (without private key) + const cert = getPublicCertificate(); + + // Extract certificate content (remove headers and format) + const certContent = cert + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\n/g, ''); + + // Build SAML Metadata XML + const metadata = ` + + + + + + + + ${certContent} + + + + + + + + + ${certContent} + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + + + + + + + + + +`; + + return metadata; +} + +/** + * Get SAML metadata configuration (for programmatic access) + */ +export function getSAMLMetadataConfig(baseUrl?: string): SAMLMetadataConfig { + const issuer = baseUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + + return { + issuer: `${issuer}/saml/metadata`, + ssoServiceUrl: `${issuer}/api/saml/sso`, + certificate: getPublicCertificate(), + }; +} diff --git a/src/lib/saml/types.ts b/src/lib/saml/types.ts new file mode 100644 index 0000000..125bd57 --- /dev/null +++ b/src/lib/saml/types.ts @@ -0,0 +1,38 @@ +/** + * SAML 2.0 Type Definitions + */ + +export interface SAMLServiceProvider { + id: number; + entity_id: string; + name: string; + description?: string; + acs_url: string; // Assertion Consumer Service URL + metadata_url?: string; + sp_certificate?: string; // Service Provider's public certificate + attribute_mapping: Record; // Map SAML attributes to user fields + sign_assertions: boolean; + encrypt_assertions: boolean; + created_at: string; + updated_at: string; +} + +export interface SAMLAttributeMapping { + email?: string; + name?: string; + firstName?: string; + lastName?: string; + groups?: string; + [key: string]: string | undefined; +} + +export interface SAMLAssertionParams { + issuer: string; + recipient: string; + audience: string; + nameId: string; + nameIdFormat: string; + attributes: Record; + sessionIndex?: string; + authnContextClassRef?: string; +} diff --git a/src/lib/schema.sql b/src/lib/schema.sql index 209889d..563b72e 100644 --- a/src/lib/schema.sql +++ b/src/lib/schema.sql @@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, + email TEXT, + tags TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -18,3 +20,5 @@ CREATE TABLE IF NOT EXISTS config ( data TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + +-- OAuth2/OIDC Provider tables are loaded separately from schema_oauth.sql diff --git a/src/lib/schema_federated.sql b/src/lib/schema_federated.sql new file mode 100644 index 0000000..381f604 --- /dev/null +++ b/src/lib/schema_federated.sql @@ -0,0 +1,33 @@ +-- External Identity Providers (OIDC Relying Party Config) +CREATE TABLE IF NOT EXISTS auth_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + issuer TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + authorization_endpoint TEXT, + token_endpoint TEXT, + userinfo_endpoint TEXT, + jwks_uri TEXT, + enabled BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Federated Identities (Links external users to local users) +CREATE TABLE IF NOT EXISTS federated_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + provider_slug TEXT NOT NULL, + subject TEXT NOT NULL, + email TEXT, -- Snapshot of external email + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider_slug, subject), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(provider_slug) REFERENCES auth_providers(slug) ON DELETE CASCADE +); + +-- Index for fast lookups during login +CREATE INDEX IF NOT EXISTS idx_federated_lookup ON federated_identities(provider_slug, subject); +CREATE INDEX IF NOT EXISTS idx_federated_user ON federated_identities(user_id); diff --git a/src/lib/schema_oauth.sql b/src/lib/schema_oauth.sql new file mode 100644 index 0000000..81e06fc --- /dev/null +++ b/src/lib/schema_oauth.sql @@ -0,0 +1,129 @@ +-- OAuth2/OIDC Provider Tables + +-- OAuth2 Clients (Applications that can use SSO) +CREATE TABLE IF NOT EXISTS oauth_clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT UNIQUE NOT NULL, + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs + allowed_scopes TEXT NOT NULL, -- JSON array of allowed scopes + grant_types TEXT NOT NULL DEFAULT '["authorization_code","refresh_token"]', -- JSON array of allowed grant types + is_confidential INTEGER DEFAULT 1, -- 1 for confidential (can keep secret), 0 for public + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- OAuth2 Authorization Codes (temporary codes in authorization code flow) +CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, -- JSON array of granted scopes + code_challenge TEXT, -- PKCE code challenge + code_challenge_method TEXT, -- PKCE method (S256 or plain) + nonce TEXT, -- OIDC nonce for ID token + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + used INTEGER DEFAULT 0, -- 1 if code has been exchanged + FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- OAuth2 Access Tokens +CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + user_id INTEGER, -- NULL for client_credentials grant + scopes TEXT NOT NULL, -- JSON array of scopes + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + revoked INTEGER DEFAULT 0, -- 1 if token has been revoked + FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- OAuth2 Refresh Tokens +CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + access_token_id INTEGER NOT NULL, + client_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + scopes TEXT NOT NULL, -- JSON array of scopes + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + revoked INTEGER DEFAULT 0, -- 1 if token has been revoked + FOREIGN KEY (access_token_id) REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, + FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- OAuth2 User Consents (remember which apps user has authorized) +CREATE TABLE IF NOT EXISTS oauth_user_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, -- JSON array of granted scopes + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, client_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id) ON DELETE CASCADE +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_authorization_codes(client_id); +CREATE INDEX IF NOT EXISTS idx_oauth_codes_user ON oauth_authorization_codes(user_id); +CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_authorization_codes(expires_at); +CREATE INDEX IF NOT EXISTS idx_oauth_access_client ON oauth_access_tokens(client_id); +CREATE INDEX IF NOT EXISTS idx_oauth_access_user ON oauth_access_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_oauth_access_expires ON oauth_access_tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_oauth_refresh_client ON oauth_refresh_tokens(client_id); +CREATE INDEX IF NOT EXISTS idx_oauth_refresh_user ON oauth_refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_oauth_user_consents_user ON oauth_user_consents(user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_user_consents_unique ON oauth_user_consents(user_id, client_id); + +-- ============================================================================ +-- SAML 2.0 Service Providers (for SAML SSO) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS saml_service_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + acs_url TEXT NOT NULL, -- Assertion Consumer Service URL + metadata_url TEXT, + sp_certificate TEXT, -- Service Provider's signing certificate (optional) + attribute_mapping TEXT, -- JSON: map SAML attributes to user fields + sign_assertions INTEGER DEFAULT 1, -- Sign SAML assertions + encrypt_assertions INTEGER DEFAULT 0, -- Encrypt SAML assertions + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_saml_sp_entity ON saml_service_providers(entity_id); + +-- ============================================================================ +-- Protected Applications (for Authentication Proxy) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS protected_applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, -- URL slug: /proxy/{slug}/* + backend_url TEXT NOT NULL, -- Backend service URL + require_auth INTEGER DEFAULT 1, -- Require authentication + allowed_users TEXT, -- JSON array of usernames, or null for all + inject_headers INTEGER DEFAULT 1, -- Inject auth headers (X-Auth-User, etc.) + strip_auth_header INTEGER DEFAULT 1, -- Strip Authorization header before proxying + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_protected_apps_slug ON protected_applications(slug); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index eb98411..377bf48 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -21,6 +21,7 @@ export const serviceSchema = z.object({ description: z.string().optional(), color: z.string().optional(), ping: z.string().optional(), + tags: z.array(z.string()).optional(), createdAt: z.number().optional(), updatedAt: z.number().optional(), }); @@ -80,6 +81,7 @@ export const loginSchema = z.object({ export const registerSchema = z.object({ username: usernameSchema, + email: z.string().email('Invalid email address').optional(), password: passwordSchema, }); @@ -90,6 +92,8 @@ export const changePasswordSchema = z.object({ export const createUserSchema = z.object({ username: usernameSchema, + email: z.string().email('Invalid email address').optional(), + tags: z.array(z.string()).optional(), password: passwordSchema, }); diff --git a/src/proxy.ts b/src/proxy.ts index a637a5f..c841ce4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'; // Routes that don't require authentication // Note: /api/config GET is public for initial setup, but POST requires auth (checked in route) -const publicRoutes = ['/login', '/onboard', '/api/auth', '/api/config']; +const publicRoutes = ['/login', '/onboard', '/api/auth', '/api/config', '/api/oauth', '/.well-known']; // Named export 'proxy' for Next.js 16 export function proxy(request: NextRequest) { diff --git a/src/types/index.ts b/src/types/index.ts index 3d5430f..559efa6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,7 @@ export interface Service { description?: string; color?: string; // Optional accent color override ping?: string; // Optional Host/IP for ICMP ping + tags?: string[]; // Access control tags createdAt?: number; updatedAt?: number; } diff --git a/src/types/node-jose.d.ts b/src/types/node-jose.d.ts new file mode 100644 index 0000000..6ce3d8e --- /dev/null +++ b/src/types/node-jose.d.ts @@ -0,0 +1,28 @@ +declare module 'node-jose' { + export namespace JWK { + interface KeyStore { + get(kid: string): Promise; + all(opts?: any): Promise; + } + interface Key { + kid: string; + kty: string; + thumbprint(hash: string): Promise; + } + function asKeyStore(jwks: any): Promise; + function createKeyStore(): KeyStore; + } + + export namespace JWS { + interface VerifyResult { + header: any; + payload: Buffer; + key: JWK.Key; + protected: any; + } + interface Verifier { + verify(input: string): Promise; + } + function createVerify(keystore: JWK.KeyStore): Verifier; + } +} From 18e4fcd136a0d5a39c47b26738a7812d57fe5eea Mon Sep 17 00:00:00 2001 From: vishnubyrraju Date: Tue, 6 Jan 2026 08:43:05 +0530 Subject: [PATCH 02/17] Fixed User Management SSO and UI --- src/app/api/auth/[provider]/callback/route.ts | 151 +++++++---- src/app/api/auth/[provider]/login/route.ts | 3 +- src/app/api/auth/discovery/route.ts | 21 ++ src/app/api/auth/providers/route.ts | 45 +++- src/app/api/users/route.ts | 25 +- src/app/settings/page.tsx | 191 +++++++------ src/components/Dashboard.tsx | 4 +- src/components/oauth/AuthProviderManager.tsx | 252 +++++++++++++++--- .../settings/EditableTable.module.css | 174 ++++++++++++ src/components/settings/EditableTable.tsx | 153 +++++++++++ src/components/settings/WidgetTable.tsx | 131 +++++++++ src/components/ui/UserManagement.module.css | 216 ++++++++------- src/components/ui/UserManagement.tsx | 118 +++++--- src/lib/auth-providers.ts | 21 +- src/lib/auth.ts | 1 + src/lib/db.ts | 40 ++- src/lib/oidc/client-discovery.ts | 29 ++ src/lib/saml/metadata.ts | 46 ++-- src/lib/schema.sql | 1 + src/lib/schema_federated.sql | 1 + src/lib/validation.ts | 7 +- src/types/node-jose.d.ts | 8 +- 22 files changed, 1282 insertions(+), 356 deletions(-) create mode 100644 src/app/api/auth/discovery/route.ts create mode 100644 src/components/settings/EditableTable.module.css create mode 100644 src/components/settings/EditableTable.tsx create mode 100644 src/components/settings/WidgetTable.tsx diff --git a/src/app/api/auth/[provider]/callback/route.ts b/src/app/api/auth/[provider]/callback/route.ts index ae80024..e0fd531 100644 --- a/src/app/api/auth/[provider]/callback/route.ts +++ b/src/app/api/auth/[provider]/callback/route.ts @@ -67,11 +67,22 @@ export async function GET( // 3. Discover Endpoints if needed let tokenEndpoint = provider.token_endpoint; let jwksUri = provider.jwks_uri; + let userInfoEndpoint = provider.userinfo_endpoint; + + // Only try discovery if we are missing critical endpoints AND we have an issuer that looks like a URL + if ((!tokenEndpoint || !jwksUri) && provider.issuer.startsWith('http')) { + try { + const config = await fetchOIDCConfiguration(provider.issuer); + tokenEndpoint = config.token_endpoint || tokenEndpoint; + jwksUri = config.jwks_uri || jwksUri; + userInfoEndpoint = config.userinfo_endpoint || userInfoEndpoint; + } catch (e) { + console.warn('Discovery failed, proceeding with stored config:', e); + } + } - if (!tokenEndpoint || !jwksUri) { - const config = await fetchOIDCConfiguration(provider.issuer); - tokenEndpoint = config.token_endpoint; - jwksUri = config.jwks_uri; + if (!tokenEndpoint) { + throw new Error('Missing token endpoint'); } // 4. Exchange Code @@ -79,16 +90,23 @@ export async function GET( const protocol = request.headers.get('x-forwarded-proto') || 'http'; const redirectUri = `${protocol}://${host}/api/auth/${slug}/callback`; + // GitHub specifically requires 'Accept: application/json' + const tokenHeaders = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }; + const tokenParams = new URLSearchParams(); - tokenParams.append('grant_type', 'authorization_code'); - tokenParams.append('code', code); - tokenParams.append('redirect_uri', redirectUri); tokenParams.append('client_id', provider.client_id); tokenParams.append('client_secret', provider.client_secret); + tokenParams.append('code', code); + tokenParams.append('redirect_uri', redirectUri); + // Some providers need grant_type, GitHub doesn't strictly enforce but it's spec compliant + tokenParams.append('grant_type', 'authorization_code'); const tokenRes = await fetch(tokenEndpoint, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: tokenHeaders, body: tokenParams }); @@ -99,82 +117,104 @@ export async function GET( } const tokens = await tokenRes.json(); - const idToken = tokens.id_token; - - if (!idToken) throw new Error('No id_token returned'); - // 5. Verify ID Token - // Fetch JWKS - const jwksRes = await fetch(jwksUri, { next: { revalidate: 3600 } }); - const jwks = await jwksRes.json(); - - const keystore = await jose.JWK.asKeyStore(jwks); - - // Validate signature - const result = await jose.JWS.createVerify(keystore).verify(idToken); - const payload = JSON.parse(result.payload.toString()); + let subject: string | null = null; + let email: string | null = null; + let username: string | null = null; + + // 5. Try OIDC (ID Token) first + if (tokens.id_token && jwksUri) { + // ... Existing OIDC Verification Logic ... + const jwksRes = await fetch(jwksUri, { next: { revalidate: 3600 } }); + const jwks = await jwksRes.json(); + const keystore = await jose.JWK.asKeyStore(jwks); + const result = await jose.JWS.createVerify(keystore).verify(tokens.id_token); + const payload = JSON.parse(result.payload.toString()); + + // Validate Issuer/Audience if possible + if (!payload.iss.startsWith(provider.issuer) && !provider.issuer.startsWith(payload.iss)) { + console.warn(`Issuer mismatch warning: ${payload.iss} vs ${provider.issuer}`); + } - // Validate claims - // Issuer check: Payload issuer must match (or start with) provider issuer - // Some providers add trailing slashes, so simple verify - if (!payload.iss.startsWith(provider.issuer) && !provider.issuer.startsWith(payload.iss)) { - // throw new Error(`Issuer mismatch: ${payload.iss} vs ${provider.issuer}`); - // Warn only for now as distinct trailing slashes are common - console.warn(`Issuer mismatch warning: ${payload.iss} vs ${provider.issuer}`); + subject = payload.sub; + email = payload.email; + username = payload.preferred_username || payload.name; } - // Aud check - const aud = payload.aud; - if (Array.isArray(aud)) { - if (!aud.includes(provider.client_id)) throw new Error('Audience mismatch'); - } else if (aud !== provider.client_id) { - throw new Error(`Audience mismatch: ${aud} vs ${provider.client_id}`); + // 6. Fallback to UserInfo Endpoint (OAuth2) + if (!subject && userInfoEndpoint) { + const userRes = await fetch(userInfoEndpoint, { + headers: { + 'Authorization': `Bearer ${tokens.access_token}`, + 'Accept': 'application/json' + } + }); + + if (userRes.ok) { + const profile = await userRes.json(); + // Map common fields + subject = String(profile.id || profile.sub); // GitHub uses 'id' (number), OIDC uses 'sub' + email = profile.email; + username = profile.login || profile.preferred_username || profile.name; + + // Handle GitHub private emails + if (!email && slug === 'github' && tokens.access_token) { + try { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { + 'Authorization': `Bearer ${tokens.access_token}`, + 'Accept': 'application/json' + } + }); + + if (emailRes.ok) { + const emails = await emailRes.json(); + // Find primary verified email + const primaryEmail = emails.find((e: any) => e.primary && e.verified); + if (primaryEmail) { + email = primaryEmail.email; + } + } + } catch (e) { + console.error('Failed to fetch user emails from GitHub:', e); + } + } + } } - // 6. User Linking / Creation - const subject = payload.sub; - const email = payload.email; // Requires 'email' scope + if (!subject) { + throw new Error('Could not identify user from ID Token or UserInfo'); + } + // 7. User Linking / Creation let federatedIdentity = getFederatedIdentity(slug, subject); let userId: number; if (federatedIdentity) { - // User exists, log them in userId = federatedIdentity.user_id; } else { - // New federated user - // Check if email exists let existingUser = email ? getUserByEmail(email) : null; if (existingUser) { - // Link to existing user userId = existingUser.id; } else { - // Create new user - const username = payload.preferred_username || payload.name || email?.split('@')[0] || `user_${subject.substring(0, 8)}`; - // Handle username collision logic... simplified for now - // We generate a dummy password hash + const finalUsername = username || email?.split('@')[0] || `user_${subject.substring(0, 8)}`; const dummyHash = '$2a$10$federated_dummy_hash_auth_' + crypto.randomUUID(); try { - const newUser = createUser(username, dummyHash, email); + const newUser = createUser(finalUsername, dummyHash, email || undefined); userId = newUser.id; } catch (e) { - // If username taken, try appending random suffix - const uniqueName = `${username}_${crypto.randomUUID().substring(0, 4)}`; - const newUser = createUser(uniqueName, dummyHash, email); + const uniqueName = `${finalUsername}_${crypto.randomUUID().substring(0, 4)}`; + const newUser = createUser(uniqueName, dummyHash, email || undefined); userId = newUser.id; } } - - // Create Link - linkFederatedIdentity(userId, slug, subject, email); + linkFederatedIdentity(userId, slug, subject, email || undefined); } - // 7. Create Session + // 8. Create Session const session = createSession(userId); - - // Set session Cookie const response = NextResponse.redirect(new URL('/', request.url)); const isSecure = process.env.COOKIE_SECURE === 'true'; @@ -182,7 +222,6 @@ export async function GET( httpOnly: true, secure: isSecure, sameSite: 'lax', - // db.ts says 7 days expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), path: '/', }); diff --git a/src/app/api/auth/[provider]/login/route.ts b/src/app/api/auth/[provider]/login/route.ts index 8c9f205..d7b1077 100644 --- a/src/app/api/auth/[provider]/login/route.ts +++ b/src/app/api/auth/[provider]/login/route.ts @@ -36,7 +36,8 @@ export async function GET( url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', provider.client_id); url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('scope', 'openid profile email'); + // Use configured scopes or default to standard OIDC + url.searchParams.set('scope', provider.scopes || 'openid profile email'); url.searchParams.set('state', state); url.searchParams.set('nonce', nonce); diff --git a/src/app/api/auth/discovery/route.ts b/src/app/api/auth/discovery/route.ts new file mode 100644 index 0000000..12bdf62 --- /dev/null +++ b/src/app/api/auth/discovery/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchOIDCConfiguration } from '@/lib/oidc/client-discovery'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + try { + const config = await fetchOIDCConfiguration(url); + return NextResponse.json(config); + } catch (error) { + console.error('Proxy discovery failed:', error); + return NextResponse.json({ error: 'Failed to discover OIDC configuration' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts index 711956f..5686441 100644 --- a/src/app/api/auth/providers/route.ts +++ b/src/app/api/auth/providers/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCurrentUser } from '@/lib/auth'; import { createAuthProvider, - listAuthProviders, + getAllAuthProviders, listEnabledAuthProviders, AuthProvider } from '@/lib/auth-providers'; @@ -24,7 +24,7 @@ export async function GET(request: NextRequest) { if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const providers = listAuthProviders(); + const providers = getAllAuthProviders(); // Scrub secrets const safeProviders = providers.map(p => ({ ...p, client_secret: undefined })); return NextResponse.json(safeProviders); @@ -74,6 +74,7 @@ export async function POST(request: NextRequest) { token_endpoint: body.token_endpoint, userinfo_endpoint: body.userinfo_endpoint, jwks_uri: body.jwks_uri, + scopes: body.scopes, enabled: body.enabled !== false // Default to true }); @@ -117,3 +118,43 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } } + +// Protected endpoint to update a provider +export async function PUT(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + + if (!body.slug) { + return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + } + + const { updateAuthProvider } = await import('@/lib/auth-providers'); + const success = updateAuthProvider(body.slug, { + name: body.name, + issuer: body.issuer, + client_id: body.client_id, + client_secret: body.client_secret, + authorization_endpoint: body.authorization_endpoint, + token_endpoint: body.token_endpoint, + userinfo_endpoint: body.userinfo_endpoint, + jwks_uri: body.jwks_uri, + scopes: body.scopes, + enabled: body.enabled + }); + + if (success) { + return NextResponse.json({ success: true }); + } else { + return NextResponse.json({ error: 'Provider not found or update failed' }, { status: 404 }); + } + + } catch (error) { + console.error('Failed to update provider:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index c6a7d11..7e0e843 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -35,20 +35,21 @@ export async function POST(req: NextRequest) { ); } - const { username, password, email, tags } = validationResult.data; + const { username, password, email, tags, role } = validationResult.data; // Hash password first const passHash = await hashPassword(password); try { // createUser handles race conditions internally - const newUser = createUser(username, passHash, email, tags); + const newUser = createUser(username, passHash, email, tags, role); return NextResponse.json({ id: newUser.id, username: newUser.username, email: newUser.email, tags: newUser.tags, + role: newUser.role, created_at: newUser.created_at }); } catch (e: unknown) { @@ -120,14 +121,28 @@ export async function PUT(req: NextRequest) { ); } - const { id, password } = validationResult.data; + const { id, password, role, tags } = validationResult.data; const user = getUserById(id); if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - const passHash = await hashPassword(password); - updateUserPassword(id, passHash); + if (password) { + const passHash = await hashPassword(password); + updateUserPassword(id, passHash); + } + + if (role) { + // Need to import updateUserRole from db first (added it in db.ts changes) + // But I must ensure it is exported in db.ts + const { updateUserRole } = await import('@/lib/db'); + updateUserRole(id, role); + } + + if (tags) { + const { updateUserTags } = await import('@/lib/db'); + updateUserTags(id, tags); + } return NextResponse.json({ success: true }); } catch (e: unknown) { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index dd78c44..cc0f4ff 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -7,18 +7,18 @@ import { toast } from 'sonner'; import { Service, Link as AppLink, Widget } from '@/types'; import { useTheme } from '@/context/ThemeContext'; import AddServiceModal from '@/components/modals/AddServiceModal'; -import EditServiceModal from '@/components/modals/EditServiceModal'; import AddWidgetModal from '@/components/modals/AddWidgetModal'; -import EditWidgetModal from '@/components/modals/EditWidgetModal'; import UserManagement from '@/components/ui/UserManagement'; import ClientManager from '@/components/oauth/ClientManager'; import AuthProviderManager from '@/components/oauth/AuthProviderManager'; +import EditableTable from '@/components/settings/EditableTable'; +import WidgetTable from '@/components/settings/WidgetTable'; import { useConfig } from '@/context/ConfigContext'; import styles from './page.module.css'; export default function SettingsPage() { const { theme, toggleTheme } = useTheme(); - const { config, updateConfig, loading } = useConfig(); + const { config, updateConfig, loading: contextLoading } = useConfig(); const [activeModal, setActiveModal] = useState<'add-app' | 'edit-app' | 'add-link' | 'edit-link' | 'config' | 'add-widget' | 'edit-widget' | null>(null); const [configJson, setConfigJson] = useState(''); const [editingItem, setEditingItem] = useState(null); @@ -27,6 +27,10 @@ export default function SettingsPage() { const [localLocation, setLocalLocation] = useState(''); const [activeSection, setActiveSection] = useState('general'); + // Role protection + const [loading, setLoading] = useState(true); + const [userRole, setUserRole] = useState<'admin' | 'member' | null>(null); + useEffect(() => { if (config) { setLocalTitle(config.title || ''); @@ -34,6 +38,38 @@ export default function SettingsPage() { } }, [config]); + useEffect(() => { + // Check current user role + fetch('/api/auth/session') + .then(res => res.json()) + .then(data => { + if (data.user) { + setUserRole(data.user.role || 'member'); + } + setLoading(false); + }) + .catch(err => { + console.error(err); + setLoading(false); + }); + }, []); + + if (loading || contextLoading) return
Loading...
; + + if (userRole !== 'admin') { + return ( +
+
+

Access Denied

+

You do not have permission to view this page.

+ +
+
+ ); + } + const updateLayout = (key: string, value: string | number | boolean) => { if (!config) return; updateConfig({ @@ -163,8 +199,11 @@ export default function SettingsPage() { reader.readAsText(file); }; + + const renderContent = () => { switch (activeSection) { + // ... (case general stays same) case 'general': return (
@@ -262,7 +301,15 @@ export default function SettingsPage() { case 'applications': return (
-

Applications

+
+
+

Applications

+

Manage your dashboard applications.

+
+ +
@@ -274,62 +321,68 @@ export default function SettingsPage() {
-
- - - -
+ { + setEditingItem(service); + setActiveModal('add-app'); + }} + />
); case 'bookmarks': return (
-

Bookmarks

-
+
+
+

Bookmarks

+

Manage your quick access bookmarks.

+
- -
+ + { + setEditingItem(service); + setActiveModal('add-link'); + }} + />
); case 'widgets': return (
-

Widgets

-
+
+
+

Widgets

+

Manage information widgets.

+
- -
+ + { + if (!config) return; + updateConfig({ + ...config, + widgets: config.widgets?.filter(w => !ids.includes(w.id)) + }); + }} + onEdit={(widget) => { + setEditingWidget(widget); + setActiveModal('add-widget'); + }} + />
); case 'users': @@ -376,6 +429,17 @@ export default function SettingsPage() {

Allow users to sign in to Atom using external services like Google, Authentik, or GitHub.

+ +
+

Callback URL / Redirect URI

+

When configuring your identity provider, set the Callback URL to:

+ + {typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/auth/[slug]/callback + +

+ Replace [slug] with the identifier you enter below (e.g., authentik). +

+
@@ -514,49 +578,6 @@ export default function SettingsPage() { }} /> )} - {activeModal === 'edit-app' && ( - setActiveModal(null)} - onDelete={handleDeleteServices} - onEdit={(service) => { - setEditingItem(service); - // Close edit modal, open add (edit) modal - setActiveModal('add-app'); - }} - /> - )} - {activeModal === 'edit-link' && ( - setActiveModal(null)} - onDelete={handleDeleteLinks} - onEdit={(service) => { - setEditingItem(service); - setActiveModal('add-link'); - }} - /> - )} - {activeModal === 'edit-widget' && ( - setActiveModal(null)} - onDelete={(ids) => { - if (!config) return; - updateConfig({ - ...config, - widgets: config.widgets?.filter(w => !ids.includes(w.id)) - }); - setActiveModal(null); - }} - onEdit={(widget) => { - setEditingWidget(widget); - setActiveModal('add-widget'); - }} - /> - )} {/* Config Editor Modal */} {activeModal === 'config' && ( diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 8f34552..6e8c8a9 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -15,7 +15,7 @@ import styles from './Dashboard.module.css'; import { useConfig } from '@/context/ConfigContext'; -export default function Dashboard({ user }: { user?: { username: string; tags?: string[] } }) { +export default function Dashboard({ user }: { user?: { username: string; tags?: string[]; role?: string } }) { const { config, updateConfig, loading } = useConfig(); const [search, setSearch] = useState(''); const [layout, setLayout] = useState<'list' | 'grid4' | 'grid6'>('grid6'); @@ -118,7 +118,7 @@ export default function Dashboard({ user }: { user?: { username: string; tags?: }; const userTags = user?.tags || []; - const hasAllAccess = userTags.includes('all'); + const hasAllAccess = userTags.includes('all') || user?.role === 'admin'; const filteredServices = config.services.filter(s => { // Tag Access Control diff --git a/src/components/oauth/AuthProviderManager.tsx b/src/components/oauth/AuthProviderManager.tsx index 5731355..8db4a17 100644 --- a/src/components/oauth/AuthProviderManager.tsx +++ b/src/components/oauth/AuthProviderManager.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Plus, Trash2, Globe, Key, Shield, AlertTriangle } from 'lucide-react'; +import { Plus, Trash2, Globe, Edit3 } from 'lucide-react'; import { toast } from 'sonner'; import styles from './ClientManager.module.css'; // Reuse existing styles if compatible or duplicate @@ -17,6 +17,7 @@ export default function AuthProviderManager() { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); // Form State const [name, setName] = useState(''); @@ -24,6 +25,10 @@ export default function AuthProviderManager() { const [issuer, setIssuer] = useState(''); const [clientId, setClientId] = useState(''); const [clientSecret, setClientSecret] = useState(''); + const [authEndpoint, setAuthEndpoint] = useState(''); + const [tokenEndpoint, setTokenEndpoint] = useState(''); + const [userInfoEndpoint, setUserInfoEndpoint] = useState(''); + const [scopes, setScopes] = useState(''); const fetchProviders = async () => { try { @@ -44,37 +49,77 @@ export default function AuthProviderManager() { fetchProviders(); }, []); + const resetForm = () => { + setName(''); + setSlug(''); + setIssuer(''); + setClientId(''); + setClientSecret(''); + setAuthEndpoint(''); + setTokenEndpoint(''); + setUserInfoEndpoint(''); + setScopes(''); + setEditingProvider(null); + setShowForm(false); + }; + + const handleEdit = (provider: AuthProvider) => { + setName(provider.name); + setSlug(provider.slug); + setIssuer(provider.issuer); + setClientId(provider.client_id); + // Secrets are not returned by API for security, so leave blank or handle "unchanged" logic + // For now user has to re-enter secret if they edit, or we make it optional in backend validation? + // Backend updateAuthProvider handles partial updates. If secret is empty string, we shouldn't send it? + // Let's assume we need to re-enter for now, or send only if changed. + // Actually, backend expects full object or partial. + setClientSecret(''); // User must re-enter or we need to handle "keep existing" logic + + // Endpoints might not be in the list API response if we didn't add them to the select? + // The list API does 'SELECT *', so they should be there. + setAuthEndpoint((provider as any).authorization_endpoint || ''); + setTokenEndpoint((provider as any).token_endpoint || ''); + setUserInfoEndpoint((provider as any).userinfo_endpoint || ''); + setScopes((provider as any).scopes || ''); + + setEditingProvider(provider.slug); + setShowForm(true); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { + const method = editingProvider ? 'PUT' : 'POST'; + const body: any = { + name, + slug: slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'), + issuer, + client_id: clientId, + authorization_endpoint: authEndpoint || undefined, + token_endpoint: tokenEndpoint || undefined, + userinfo_endpoint: userInfoEndpoint || undefined, + scopes: scopes || undefined, + enabled: true + }; + + // Only send secret if it's a new provider OR if the user entered a new one during edit + if (!editingProvider || clientSecret) { + body.client_secret = clientSecret; + } + const res = await fetch('/api/auth/providers', { - method: 'POST', + method: method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name, - slug: slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'), // Basic slugify - issuer, - client_id: clientId, - client_secret: clientSecret, - enabled: true - }) + body: JSON.stringify(body) }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Failed to create provider'); - - toast.success('Identity Provider added successfully'); - setShowForm(false); - - // Reset form - setName(''); - setSlug(''); - setIssuer(''); - setClientId(''); - setClientSecret(''); + if (!res.ok) throw new Error(data.error || 'Failed to save provider'); + toast.success(editingProvider ? 'Provider updated' : 'Provider added'); + resetForm(); fetchProviders(); } catch (error: unknown) { const errorMsg = error instanceof Error ? error.message : 'An error occurred'; @@ -109,7 +154,7 @@ export default function AuthProviderManager() {

Configure external services (Google, Authentik, etc.) for Single Sign-On.

{!showForm && ( - )} @@ -117,6 +162,7 @@ export default function AuthProviderManager() { {showForm && (
+

{editingProvider ? 'Edit Provider' : 'Add Provider'}

@@ -126,7 +172,7 @@ export default function AuthProviderManager() { value={name} onChange={e => { setName(e.target.value); - if (!slug) setSlug(e.target.value.toLowerCase().replace(/\s+/g, '-')); + if (!editingProvider && !slug) setSlug(e.target.value.toLowerCase().replace(/\s+/g, '-')); }} required /> @@ -139,6 +185,8 @@ export default function AuthProviderManager() { value={slug} onChange={e => setSlug(e.target.value)} required + disabled={!!editingProvider} // Disable slug editing for now to simplify update logic + style={editingProvider ? { opacity: 0.7, cursor: 'not-allowed' } : {}} /> Used in URL: /api/auth/{slug || '...'}/login @@ -155,6 +203,45 @@ export default function AuthProviderManager() { onChange={e => setIssuer(e.target.value)} required /> +
@@ -168,19 +255,67 @@ export default function AuthProviderManager() { />
- + setClientSecret(e.target.value)} - required + required={!editingProvider} />
+
+ + setScopes(e.target.value)} + /> + + Space-separated list of scopes. Defaults to 'openid profile email'. + +
+ +
+

Manual Configuration (Optional)

+

+ Required for providers that don't support OIDC Discovery (e.g., GitHub). + Leave blank to attempt auto-discovery from Issuer URL. +

+ +
+ + setAuthEndpoint(e.target.value)} + /> +
+
+ + setTokenEndpoint(e.target.value)} + /> +
+
+ + setUserInfoEndpoint(e.target.value)} + /> +
+
- - + +
@@ -193,23 +328,64 @@ export default function AuthProviderManager() {
No identity providers configured.
) : ( providers.map(p => ( -
+
-
+
{p.name} - {p.slug} + {p.slug}
-
+
{p.issuer}
- +
+ + +
)) )} diff --git a/src/components/settings/EditableTable.module.css b/src/components/settings/EditableTable.module.css new file mode 100644 index 0000000..9e216bf --- /dev/null +++ b/src/components/settings/EditableTable.module.css @@ -0,0 +1,174 @@ +.container { + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); + overflow: hidden; + margin-top: 1rem; +} + +.toolbar { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border-color); + gap: 1rem; +} + +.searchBar { + flex: 1; + max-width: 300px; + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; +} + +.searchBar:focus { + outline: none; + border-color: var(--accent-color); +} + +.deleteBtn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.85rem; + background: rgba(255, 68, 68, 0.1); + color: #ff4444; + border: 1px solid rgba(255, 68, 68, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.deleteBtn:hover { + background: rgba(255, 68, 68, 0.2); +} + +.tableWrapper { + width: 100%; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.table th { + text-align: left; + padding: 0.75rem 1rem; + color: var(--text-secondary); + font-weight: 500; + border-bottom: 1px solid var(--border-color); + white-space: nowrap; +} + +.table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.table tr:last-child td { + border-bottom: none; +} + +.table tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.selectedRow { + background: rgba(var(--accent-rgb), 0.05) !important; +} + +.iconPreview { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + border-radius: 6px; + color: var(--text-primary); + margin: 0 auto; +} + +.noIcon { + color: var(--text-muted); +} + +.titleCell { + min-width: 150px; +} + +.titleText { + font-weight: 500; + color: var(--text-primary); +} + +.subtitle { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 2px; + max-width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.urlCell { + color: var(--text-muted); + font-family: monospace; + font-size: 0.85rem; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.link { + color: inherit; + text-decoration: none; +} + +.link:hover { + text-decoration: underline; + color: var(--accent-color); +} + +.dateCell { + color: var(--text-muted); + font-size: 0.85rem; + white-space: nowrap; +} + +.actionBtn { + padding: 0.4rem; + color: var(--text-secondary); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.actionBtn:hover { + color: var(--accent-color); + background: rgba(var(--accent-rgb), 0.1); +} + +.footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 0.85rem; +} \ No newline at end of file diff --git a/src/components/settings/EditableTable.tsx b/src/components/settings/EditableTable.tsx new file mode 100644 index 0000000..cbc5d96 --- /dev/null +++ b/src/components/settings/EditableTable.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState } from 'react'; +import { ArrowUpDown, Edit3, Trash2 } from 'lucide-react'; +import { Service } from '@/types'; +import * as simpleIcons from 'simple-icons'; +import styles from './EditableTable.module.css'; + +interface EditableTableProps { + items: Service[]; + onDelete: (ids: string[]) => void; + onEdit: (item: Service) => void; + title?: string; +} + +export default function EditableTable({ items, onDelete, onEdit, title = "Items" }: EditableTableProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [search, setSearch] = useState(''); + + const filteredItems = items.filter(s => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.url.toLowerCase().includes(search.toLowerCase()) + ); + + const toggleSelect = (id: string) => { + const newSet = new Set(selectedIds); + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + setSelectedIds(newSet); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === filteredItems.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredItems.map(s => s.id))); + } + }; + + const handleDelete = () => { + if (confirm(`Are you sure you want to delete ${selectedIds.size} items?`)) { + onDelete(Array.from(selectedIds)); + setSelectedIds(new Set()); + } + }; + + const formatDate = (ts?: number) => { + if (!ts) return '-'; + return new Date(ts).toLocaleDateString(); + }; + + return ( +
+
+ setSearch(e.target.value)} + /> + {selectedIds.size > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + + {filteredItems.map(s => { + // Render Icon + let IconPath = null; + if (s.icon) { + const slug = 'si' + s.icon.charAt(0).toUpperCase() + s.icon.slice(1); + // @ts-expect-error SimpleIcons indexing by string key + const iconData = simpleIcons[slug]; + if (iconData) IconPath = iconData.path; + } + + return ( + + + + + + + + + ); + })} + {filteredItems.length === 0 && ( + + + + )} + +
+ 0 && selectedIds.size === filteredItems.length} + onChange={toggleSelectAll} + /> + IconTitle URLUpdated
+ toggleSelect(s.id)} + /> + +
+ {IconPath ? ( + + ) : ( +
-
+ )} +
+
+
{s.name}
+ {s.description &&
{s.description}
} +
+ + {s.url} + + {formatDate(s.updatedAt)} + +
+ No {title.toLowerCase()} found matching "{search}" +
+
+ +
+ Showing {filteredItems.length} of {items.length} items +
+
+ ); +} diff --git a/src/components/settings/WidgetTable.tsx b/src/components/settings/WidgetTable.tsx new file mode 100644 index 0000000..5804d09 --- /dev/null +++ b/src/components/settings/WidgetTable.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState } from 'react'; +import { ArrowUpDown, Edit3, Trash2 } from 'lucide-react'; +import { Widget } from '@/types'; +import styles from './EditableTable.module.css'; // Reusing styles + +interface WidgetTableProps { + widgets: Widget[]; + onDelete: (ids: string[]) => void; + onEdit: (widget: Widget) => void; +} + +export default function WidgetTable({ widgets, onDelete, onEdit }: WidgetTableProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [search, setSearch] = useState(''); + + const filteredWidgets = widgets.filter(w => + (w.title || '').toLowerCase().includes(search.toLowerCase()) || + w.type.toLowerCase().includes(search.toLowerCase()) + ); + + const toggleSelect = (id: string) => { + const newSet = new Set(selectedIds); + if (newSet.has(id)) newSet.delete(id); + else newSet.add(id); + setSelectedIds(newSet); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === filteredWidgets.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(filteredWidgets.map(w => w.id))); + } + }; + + const handleDelete = () => { + if (confirm(`Are you sure you want to delete ${selectedIds.size} widgets?`)) { + onDelete(Array.from(selectedIds)); + setSelectedIds(new Set()); + } + }; + + const renderDetails = (w: Widget) => { + if (w.type === 'generic') return (w.options as { endpoint?: string })?.endpoint || '-'; + // Safely handle options as object or string + const opts = w.options || {}; + try { + return JSON.stringify(opts).slice(0, 50) + (JSON.stringify(opts).length > 50 ? '...' : ''); + } catch { + return '-'; + } + }; + + return ( +
+
+ setSearch(e.target.value)} + /> + {selectedIds.size > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + {filteredWidgets.map(w => ( + + + + + + + + ))} + {filteredWidgets.length === 0 && ( + + + + )} + +
+ 0 && selectedIds.size === filteredWidgets.length} + onChange={toggleSelectAll} + /> + Title TypeDetails
+ toggleSelect(w.id)} + /> + {w.title}{w.type} + {renderDetails(w)} + + +
+ No widgets found matching "{search}" +
+
+ +
+ Showing {filteredWidgets.length} of {widgets.length} widgets +
+
+ ); +} diff --git a/src/components/ui/UserManagement.module.css b/src/components/ui/UserManagement.module.css index 2beba4d..cb4a548 100644 --- a/src/components/ui/UserManagement.module.css +++ b/src/components/ui/UserManagement.module.css @@ -1,75 +1,112 @@ .container { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 1.5rem; + padding: 1rem; + max-width: 800px; + margin: 0 auto; } .list { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.75rem; margin-bottom: 1.5rem; } .userRow { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1rem; display: flex; justify-content: space-between; align-items: center; - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-sm); - border: 1px solid transparent; + transition: all 0.2s; } .userRow:hover { - border-color: var(--border-color); + border-color: var(--accent-secondary); + background: var(--bg-tertiary); } .userInfo { display: flex; align-items: center; gap: 1rem; - color: var(--text-primary); - font-weight: 500; } -.userIcon { - color: var(--text-muted); +.avatarPlaceholder { + width: 40px; + height: 40px; + background: var(--bg-tertiary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); } -.date { - font-size: 0.8rem; - color: var(--text-secondary); - color: var(--text-secondary); - font-weight: 400; +.userDetails { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.email { - font-size: 0.85rem; +.userHeader { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.username { + font-weight: 600; + color: var(--text-primary); + font-size: 1.05rem; +} + +.adminBadge { + background: rgba(255, 68, 68, 0.15); + color: #ff4444; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 12px; + font-weight: 500; + text-transform: capitalize; +} + +.roleBadge { + background: var(--bg-tertiary); color: var(--text-secondary); - margin-right: 0.5rem; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 12px; + font-weight: 500; + text-transform: capitalize; } .tags { display: flex; - gap: 0.3rem; - margin-right: auto; + gap: 0.5rem; } .tag { + background: rgba(var(--accent-rgb), 0.1); + color: var(--accent-color); font-size: 0.75rem; - background: var(--bg-hover); - padding: 0.1rem 0.4rem; + padding: 2px 8px; border-radius: 4px; - color: var(--text-secondary); } -.hint { - font-size: 0.75rem; +.userMeta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; color: var(--text-muted); } +.dot { + opacity: 0.5; +} + .actions { display: flex; gap: 0.5rem; @@ -77,102 +114,98 @@ .actionBtn, .deleteBtn { - background: var(--bg-hover); - border: none; - padding: 0.5rem; - border-radius: 4px; - color: var(--text-secondary); - cursor: pointer; + width: 32px; + height: 32px; display: flex; align-items: center; justify-content: center; + border-radius: 6px; + border: none; + cursor: pointer; transition: all 0.2s; + background: transparent; + color: var(--text-secondary); } .actionBtn:hover { + background: var(--bg-primary); color: var(--text-primary); - background: var(--bg-secondary); } .deleteBtn:hover { - color: #ef4444; - background: rgba(239, 68, 68, 0.1); + background: rgba(255, 68, 68, 0.1); + color: #ff4444; } .addBtn { - width: 100%; - padding: 1rem; - background: transparent; - border: 1px dashed var(--border-color); - border-radius: var(--radius-sm); - color: var(--text-secondary); - cursor: pointer; display: flex; align-items: center; - justify-content: center; gap: 0.5rem; - transition: all 0.2s; + padding: 0.75rem 1.5rem; + background: var(--accent-color); + color: white; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + margin: 0 auto; + transition: opacity 0.2s; } .addBtn:hover { - border-color: var(--text-secondary); - color: var(--text-primary); - background: var(--bg-hover); + opacity: 0.9; } -/* Modal Styles Reuse or Custom */ +/* Modal */ .overlay { position: fixed; top: 0; left: 0; - right: 0; - bottom: 0; + width: 100vw; + height: 100vh; background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 1000; display: flex; align-items: center; justify-content: center; - z-index: 1000; } .modal { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); + background: var(--bg-secondary); width: 100%; - max-width: 400px; + max-width: 450px; + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); overflow: hidden; } .header { - padding: 1rem 1.5rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; - border-bottom: 1px solid var(--border-color); } .header h3 { margin: 0; - color: var(--text-primary); font-size: 1.1rem; } .closeBtn { - background: none; + background: transparent; border: none; color: var(--text-muted); cursor: pointer; } -.closeBtn:hover { - color: var(--text-primary); -} - .body { padding: 1.5rem; display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; } .field { @@ -184,56 +217,61 @@ .field label { font-size: 0.85rem; color: var(--text-secondary); + font-weight: 500; } -.field input { - background: var(--bg-secondary); - border: 1px solid var(--border-color); +.field input, +.select { padding: 0.75rem; - border-radius: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; color: var(--text-primary); - outline: none; + font-size: 0.95rem; } -.field input:focus { +.field input:focus, +.select:focus { + outline: none; border-color: var(--accent-color); } +.hint { + font-size: 0.75rem; + color: var(--text-muted); +} + .error { - color: #ef4444; - font-size: 0.85rem; - background: rgba(239, 68, 68, 0.1); + color: #ff4444; + font-size: 0.9rem; padding: 0.5rem; - border-radius: 4px; + background: rgba(255, 68, 68, 0.1); + border-radius: 6px; } .footer { padding: 1rem 1.5rem; - background: var(--bg-secondary); + background: var(--bg-tertiary); border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; - gap: 1rem; + gap: 0.75rem; } .cancelBtn { + padding: 0.6rem 1rem; background: transparent; - border: none; + border: 1px solid var(--border-color); + border-radius: 6px; color: var(--text-secondary); cursor: pointer; - font-weight: 500; } .saveBtn { + padding: 0.6rem 1.25rem; background: var(--accent-color); - color: #000; border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - font-weight: 600; + border-radius: 6px; + color: white; cursor: pointer; -} - -.saveBtn:hover { - opacity: 0.9; } \ No newline at end of file diff --git a/src/components/ui/UserManagement.tsx b/src/components/ui/UserManagement.tsx index 84e85e2..658bc11 100644 --- a/src/components/ui/UserManagement.tsx +++ b/src/components/ui/UserManagement.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { User, Lock, Trash2, Plus, X } from 'lucide-react'; +import { User, Edit3, Trash2, Plus, X } from 'lucide-react'; import { toast } from 'sonner'; import styles from './UserManagement.module.css'; @@ -10,6 +10,7 @@ interface UserData { username: string; email?: string; tags?: string[]; + role?: 'admin' | 'member'; created_at: string; } @@ -24,6 +25,7 @@ export default function UserManagement() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [email, setEmail] = useState(''); + const [role, setRole] = useState<'admin' | 'member'>('member'); const [tags, setTags] = useState(''); const [error, setError] = useState(''); @@ -46,8 +48,8 @@ export default function UserManagement() { }; const handleAddUser = async () => { - if (!username || !password || !email) { - setError('All fields required'); + if (!username || !password) { + setError('Username and Password are required'); return; } if (password.length < 8) { @@ -63,7 +65,8 @@ export default function UserManagement() { username, password, email, - tags: tags.split(',').map(t => t.trim()).filter(Boolean) + tags: tags.split(',').map(t => t.trim()).filter(Boolean), + role }) }); @@ -108,17 +111,26 @@ export default function UserManagement() { }; const handleChangePassword = async () => { - if (!selectedUser || !password) return; - if (password.length < 8) { + if (!selectedUser) return; + + // If password is provided, validation check + if (password && password.length < 8) { setError('Password must be at least 8 characters'); return; } try { + const body: any = { + id: selectedUser.id, + role, + tags: tags.split(',').map(t => t.trim()).filter(Boolean) + }; + if (password) body.password = password; + const res = await fetch('/api/users', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: selectedUser.id, password }) + body: JSON.stringify(body) }); if (!res.ok) { @@ -126,9 +138,16 @@ export default function UserManagement() { throw new Error(data.error); } + // Update local state + setUsers(users.map(u => u.id === selectedUser.id ? { + ...u, + role, + tags: body.tags + } : u)); + setShowPassModal(false); resetForm(); - toast.success('Password updated successfully'); + toast.success('User updated successfully'); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'An error occurred'; setError(msg); @@ -141,6 +160,7 @@ export default function UserManagement() { setPassword(''); setEmail(''); setTags(''); + setRole('member'); setError(''); setSelectedUser(null); }; @@ -153,23 +173,41 @@ export default function UserManagement() { {users.map(u => (
- - {u.username} - {u.email && ({u.email})} - {u.tags && u.tags.length > 0 && ( - - {u.tags.map(t => {t})} - - )} - Member since {new Date(u.created_at).toLocaleDateString()} +
+ +
+
+
+ {u.username} + + {u.role || 'member'} + + {u.tags && u.tags.length > 0 && ( +
+ {u.tags.map(t => {t})} +
+ )} +
+
+ {u.email || 'No email'} + + Member since {new Date(u.created_at).toLocaleDateString()} +
+
@@ -219,24 +257,38 @@ export default function UserManagement() { placeholder="user@example.com" />
-
- - setTags(e.target.value)} - placeholder="admin, dev, finance" - /> - Comma-sparated tags for access control -
)} + +
+ + +
+ +
+ + setTags(e.target.value)} + placeholder="admin, dev, finance" + /> + Comma-sparated tags for access control +
+
- + setPassword(e.target.value)} - placeholder="New Password" + placeholder={showAddModal ? "New Password" : "New Password (Optional)"} />
{error &&
{error}
} @@ -248,7 +300,7 @@ export default function UserManagement() { className={styles.saveBtn} onClick={showAddModal ? handleAddUser : handleChangePassword} > - {showAddModal ? 'Create User' : 'Update Password'} + {showAddModal ? 'Create User' : 'Save Changes'}
diff --git a/src/lib/auth-providers.ts b/src/lib/auth-providers.ts index f1b29d7..4514083 100644 --- a/src/lib/auth-providers.ts +++ b/src/lib/auth-providers.ts @@ -13,6 +13,7 @@ export interface AuthProvider { token_endpoint?: string; userinfo_endpoint?: string; jwks_uri?: string; + scopes?: string; // e.g. "openid profile email" enabled: boolean; created_at: string; updated_at: string; @@ -37,31 +38,37 @@ function getStmt(sql: string): Database.Statement { return preparedStmts.get(sql)!; } +// Helper to safely cast DB row +function mapProvider(row: unknown): AuthProvider { + const r = row as Omit & { enabled: number }; + return { ...r, enabled: r.enabled === 1 }; +} + // ============================================================================ // Auth Provider Operations // ============================================================================ export function getAuthProviderBySlug(slug: string): AuthProvider | undefined { const row = getStmt('SELECT * FROM auth_providers WHERE slug = ?').get(slug); - return row ? { ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider : undefined; + return row ? mapProvider(row) : undefined; } -export function listAuthProviders(): AuthProvider[] { +export function getAllAuthProviders(): AuthProvider[] { const rows = getStmt('SELECT * FROM auth_providers ORDER BY name ASC').all(); - return rows.map(row => ({ ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider)); + return rows.map(mapProvider); } export function listEnabledAuthProviders(): AuthProvider[] { const rows = getStmt('SELECT * FROM auth_providers WHERE enabled = 1 ORDER BY name ASC').all(); - return rows.map(row => ({ ...(row as any), enabled: (row as any).enabled === 1 } as AuthProvider)); + return rows.map(mapProvider); } export function createAuthProvider( data: Omit ): AuthProvider { const stmt = getStmt(` - INSERT INTO auth_providers (name, slug, issuer, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, enabled) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO auth_providers (name, slug, issuer, client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, scopes, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -74,6 +81,7 @@ export function createAuthProvider( data.token_endpoint || null, data.userinfo_endpoint || null, data.jwks_uri || null, + data.scopes || null, // Default scopes will be handled in business logic if null data.enabled ? 1 : 0 ); @@ -95,6 +103,7 @@ export function updateAuthProvider(slug: string, updates: Partial) if (updates.token_endpoint !== undefined) { fields.push('token_endpoint = ?'); values.push(updates.token_endpoint); } if (updates.userinfo_endpoint !== undefined) { fields.push('userinfo_endpoint = ?'); values.push(updates.userinfo_endpoint); } if (updates.jwks_uri !== undefined) { fields.push('jwks_uri = ?'); values.push(updates.jwks_uri); } + if (updates.scopes !== undefined) { fields.push('scopes = ?'); values.push(updates.scopes); } if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } if (fields.length === 0) return false; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a501f36..020d02e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -71,6 +71,7 @@ export async function getCurrentUser() { username: session.user.username, email: session.user.email, tags: session.user.tags || [], + role: session.user.role || 'member', }; } diff --git a/src/lib/db.ts b/src/lib/db.ts index d3c0db3..30408e1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -81,6 +81,15 @@ try { db.prepare('ALTER TABLE users ADD COLUMN email TEXT').run(); } + // Add 'role' column if missing + if (!userColumns.some(c => c.name === 'role')) { + console.log('Migrating database: Adding role column to users table...'); + db.prepare("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'member'").run(); + + // Set ID 1 and special users to admin + db.prepare("UPDATE users SET role = 'admin' WHERE id = 1 OR username = 'admin' OR username = 'VishnuByrraju'").run(); + } + } catch (migErr) { console.error('Auto-migration error:', migErr); } @@ -142,6 +151,7 @@ export interface User { password_hash: string; email?: string; tags?: string[]; + role: 'admin' | 'member'; created_at: string; } @@ -152,12 +162,13 @@ export interface Session { created_at: string; } -// Helper to parse user tags +// Helper to parse user tags and role function parseUser(user: any): User | undefined { if (!user) return undefined; return { ...user, - tags: user.tags ? user.tags.split(',') : [] + tags: user.tags ? user.tags.split(',') : [], + role: user.role || 'member' }; } @@ -172,11 +183,11 @@ export function getUserById(id: number): User | undefined { return parseUser(user); } -export function createUser(username: string, passwordHash: string, email?: string, tags?: string[]): User { - // Use INSERT OR IGNORE to prevent race conditions +export function createUser(username: string, passwordHash: string, email?: string, tags?: string[], role: 'admin' | 'member' = 'member'): User { + // Use INSERT OR IGNORE to prevent raise conditions const tagsStr = tags ? tags.join(',') : null; - const stmt = getStmt('INSERT INTO users (username, password_hash, email, tags) VALUES (?, ?, ?, ?)'); - const result = stmt.run(username, passwordHash, email || null, tagsStr); + const stmt = getStmt('INSERT INTO users (username, password_hash, email, tags, role) VALUES (?, ?, ?, ?, ?)'); + const result = stmt.run(username, passwordHash, email || null, tagsStr, role); // Check if insert was successful (SQLite returns changes > 0) if (result.changes === 0) { @@ -194,21 +205,30 @@ export function getUserCount(): number { export function updateUserPassword(userId: number, passwordHash: string): void { getStmt('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId); } +export function updateUserRole(userId: number, role: 'admin' | 'member'): void { + getStmt('UPDATE users SET role = ? WHERE id = ?').run(role, userId); +} +export function updateUserTags(userId: number, tags: string[]): void { + const tagsStr = tags.join(','); + getStmt('UPDATE users SET tags = ? WHERE id = ?').run(tagsStr, userId); +} export function getAllUsers(): User[] { - const users = getStmt('SELECT id, username, password_hash, email, tags, created_at FROM users').all(); + const users = getStmt('SELECT id, username, password_hash, email, tags, role, created_at FROM users').all(); return users.map((u: any) => ({ ...u, - tags: u.tags ? u.tags.split(',') : [] + tags: u.tags ? u.tags.split(',') : [], + role: u.role || 'member' })); } // Safe version that doesn't return password hashes export function getAllUsersSafe(): Omit[] { - const users = getStmt('SELECT id, username, email, tags, created_at FROM users').all(); + const users = getStmt('SELECT id, username, email, tags, role, created_at FROM users').all(); return users.map((u: any) => ({ ...u, - tags: u.tags ? u.tags.split(',') : [] + tags: u.tags ? u.tags.split(',') : [], + role: u.role || 'member' })); } diff --git a/src/lib/oidc/client-discovery.ts b/src/lib/oidc/client-discovery.ts index 3b59282..e4567b6 100644 --- a/src/lib/oidc/client-discovery.ts +++ b/src/lib/oidc/client-discovery.ts @@ -6,6 +6,21 @@ export interface OIDCProviderMetadata { jwks_uri: string; } +const KNOWN_PROVIDERS: Record> = { + 'github.com': { + authorization_endpoint: 'https://github.com/login/oauth/authorize', + token_endpoint: 'https://github.com/login/oauth/access_token', + userinfo_endpoint: 'https://api.github.com/user', + jwks_uri: '', // GitHub doesn't have standard JWKS for OIDC + }, + 'www.github.com': { + authorization_endpoint: 'https://github.com/login/oauth/authorize', + token_endpoint: 'https://github.com/login/oauth/access_token', + userinfo_endpoint: 'https://api.github.com/user', + jwks_uri: '', + } +}; + export async function fetchOIDCConfiguration(issuer: string): Promise { // Ensure issuer has no trailing slash for the suffix append, but standard says base + /.well-known const baseUrl = issuer.replace(/\/$/, ''); @@ -31,6 +46,20 @@ export async function fetchOIDCConfiguration(issuer: string): Promise + // Build SAML Metadata XML + const metadata = ` @@ -72,18 +72,18 @@ export function generateSAMLMetadata(baseUrl?: string): string { `; - return metadata; + return metadata; } /** * Get SAML metadata configuration (for programmatic access) */ export function getSAMLMetadataConfig(baseUrl?: string): SAMLMetadataConfig { - const issuer = baseUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; + const issuer = baseUrl || process.env.OAUTH_ISSUER_URL || 'http://localhost:3000'; - return { - issuer: `${issuer}/saml/metadata`, - ssoServiceUrl: `${issuer}/api/saml/sso`, - certificate: getPublicCertificate(), - }; + return { + issuer: `${issuer}/saml/metadata`, + ssoServiceUrl: `${issuer}/api/saml/sso`, + certificate: getPublicCertificate(), + }; } diff --git a/src/lib/schema.sql b/src/lib/schema.sql index 563b72e..52757c1 100644 --- a/src/lib/schema.sql +++ b/src/lib/schema.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users ( password_hash TEXT NOT NULL, email TEXT, tags TEXT, + role TEXT DEFAULT 'member', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/lib/schema_federated.sql b/src/lib/schema_federated.sql index 381f604..fd0d8db 100644 --- a/src/lib/schema_federated.sql +++ b/src/lib/schema_federated.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS auth_providers ( token_endpoint TEXT, userinfo_endpoint TEXT, jwks_uri TEXT, + scopes TEXT, enabled BOOLEAN DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 377bf48..698fc6d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -87,13 +87,16 @@ export const registerSchema = z.object({ export const changePasswordSchema = z.object({ id: z.number().int().positive(), - password: passwordSchema, + password: passwordSchema.optional(), + role: z.enum(['admin', 'member']).optional(), + tags: z.array(z.string()).optional() }); export const createUserSchema = z.object({ username: usernameSchema, - email: z.string().email('Invalid email address').optional(), + email: z.string().email('Invalid email address').optional().or(z.literal('')), tags: z.array(z.string()).optional(), password: passwordSchema, + role: z.enum(['admin', 'member']).optional().default('member') }); diff --git a/src/types/node-jose.d.ts b/src/types/node-jose.d.ts index 6ce3d8e..43f9039 100644 --- a/src/types/node-jose.d.ts +++ b/src/types/node-jose.d.ts @@ -2,23 +2,23 @@ declare module 'node-jose' { export namespace JWK { interface KeyStore { get(kid: string): Promise; - all(opts?: any): Promise; + all(opts?: object): Promise; } interface Key { kid: string; kty: string; thumbprint(hash: string): Promise; } - function asKeyStore(jwks: any): Promise; + function asKeyStore(jwks: object | string): Promise; function createKeyStore(): KeyStore; } export namespace JWS { interface VerifyResult { - header: any; + header: object; payload: Buffer; key: JWK.Key; - protected: any; + protected: object; } interface Verifier { verify(input: string): Promise; From 5e9b52e31bfa23ee1313d0fa8c1b7985a655c5b6 Mon Sep 17 00:00:00 2001 From: vishnubyrraju Date: Tue, 6 Jan 2026 08:55:02 +0530 Subject: [PATCH 03/17] Fixed OnBoarding User to get Admin Perms --- src/app/settings/page.tsx | 35 +++++++++++++++++++++++++++-------- src/lib/auth.ts | 6 +++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index cc0f4ff..2c80408 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { ArrowLeft, Save, Upload, Download, Trash2, Edit3, Plus, Sun, Moon, Code, X } from 'lucide-react'; +import { ArrowLeft, Save, Upload, Download, Trash2, Edit3, Plus, Sun, Moon, Code, X, Lock, LogOut } from 'lucide-react'; import { toast } from 'sonner'; import { Service, Link as AppLink, Widget } from '@/types'; import { useTheme } from '@/context/ThemeContext'; @@ -58,13 +58,32 @@ export default function SettingsPage() { if (userRole !== 'admin') { return ( -
-
-

Access Denied

-

You do not have permission to view this page.

- +
+
+
+
+ +
+
+

Access Denied

+

+ You do not have administrative permissions to access the settings configuration. +

+
+ + +
); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 020d02e..de70291 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -80,8 +80,12 @@ export async function register(username: string, password: string, email?: strin const hash = await hashPassword(password); try { + // Check if this is the first user (onboarding) + const isFirstUser = getUserCount() === 0; + const role = isFirstUser ? 'admin' : 'member'; + // createUser will throw if username exists (prevents race condition) - const user = createUser(username, hash, email); + const user = createUser(username, hash, email, undefined, role); // Auto-login after registration const session = createSession(user.id); From 997f3ababb1ba55548f1b97edfc59061cc7dc7a5 Mon Sep 17 00:00:00 2001 From: vishnubyrraju Date: Tue, 6 Jan 2026 11:05:57 +0530 Subject: [PATCH 04/17] Testing On Custom Widgets --- src/components/Dashboard.tsx | 10 + src/components/modals/AddWidgetModal.tsx | 222 +++++++++++++++++------ src/components/widgets/CustomWidget.tsx | 122 +++++++++++++ src/lib/validation.ts | 2 +- src/types/index.ts | 2 +- 5 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 src/components/widgets/CustomWidget.tsx diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 6e8c8a9..2785537 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Search, Grid3X3, Grid2X2, List as ListIcon, ChevronRight } from 'lucide-react'; import ServiceCard from './ui/ServiceCard'; import SystemStatsWidget from './widgets/SystemStats'; +import CustomWidget from './widgets/CustomWidget'; import DockerWidget from './widgets/DockerWidget'; import ShortcutsModal from './modals/ShortcutsModal'; import ClockWidget from './widgets/ClockWidget'; @@ -212,6 +213,15 @@ export default function Dashboard({ user }: { user?: { username: string; tags?: refreshInterval={(widget.options as { refreshInterval?: number })?.refreshInterval} /> )} + {widget.type === 'custom' && ( + + )} {widget.type === 'docker' && }
))} diff --git a/src/components/modals/AddWidgetModal.tsx b/src/components/modals/AddWidgetModal.tsx index 458691e..45dc673 100644 --- a/src/components/modals/AddWidgetModal.tsx +++ b/src/components/modals/AddWidgetModal.tsx @@ -33,6 +33,11 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg (initialData?.options?.fields as FieldConfig[]) || [{ label: '', path: '' }] ); + const [template, setTemplate] = useState(initialData?.options?.template as string || ''); + const [stylesValue, setStylesValue] = useState(initialData?.options?.styles as string || ''); + const [testData, setTestData] = useState(null); + const [testingEndpoint, setTestingEndpoint] = useState(false); + // Initialize raw JSON when entering mode - intentionally triggered by isJsonMode only useEffect(() => { if (isJsonMode) { @@ -40,7 +45,8 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg id: initialData?.id || 'widget-' + Date.now(), type, title, - options: type === 'generic' ? { endpoint, fields: fields.filter(f => f.label && f.path) } : {} + options: type === 'generic' ? { endpoint, fields: fields.filter(f => f.label && f.path) } : + type === 'custom' ? { endpoint, template, styles: stylesValue } : {} }; setRawJson(JSON.stringify(currentData, null, 2)); setJsonError(''); @@ -58,6 +64,10 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg if (parsed.type === 'generic' && parsed.options) { setEndpoint(parsed.options.endpoint || ''); setFields(parsed.options.fields || [{ label: '', path: '' }]); + } else if (parsed.type === 'custom' && parsed.options) { + setEndpoint(parsed.options.endpoint || ''); + setTemplate(parsed.options.template || ''); + setStylesValue(parsed.options.styles || ''); } setIsJsonMode(false); } catch { @@ -106,6 +116,35 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg setFields(newFields); }; + const handleTestEndpoint = async () => { + if (!endpoint) { + toast.error('Please enter an endpoint first'); + return; + } + + setTestingEndpoint(true); + setTestData(null); + + try { + const proxyUrl = `/api/proxy?url=${encodeURIComponent(endpoint)}`; + const res = await fetch(proxyUrl); + const data = await res.json(); + + if (!res.ok) { + setTestData(`Error: ${data.error || res.statusText}`); + toast.error('Test failed'); + } else { + setTestData(JSON.stringify(data, null, 2)); + toast.success('Data fetched successfully'); + } + } catch (e) { + setTestData(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`); + toast.error('Test passed with errors'); + } finally { + setTestingEndpoint(false); + } + }; + const handleSubmit = () => { let finalWidget: Widget; @@ -153,6 +192,20 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg endpoint, fields: validFields }; + } else if (type === 'custom') { + if (!endpoint) { + toast.error('Endpoint is required'); + return; + } + if (!template) { + toast.error('Template HTML is required'); + return; + } + newWidget.options = { + endpoint, + template, + styles: stylesValue + }; } finalWidget = newWidget; } @@ -164,6 +217,7 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg return (
+ {/* ... Header ... */}

{initialData ? 'Edit' : 'Add'} Widget

@@ -199,6 +253,7 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg onChange={(e) => setType(e.target.value as Widget['type'])} > + @@ -206,6 +261,7 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg
+ {/* ... Generic Preset ... */} {type === 'generic' && !initialData && (
@@ -228,65 +284,127 @@ export default function AddWidgetModal({ onClose, onSave, initialData }: AddWidg />
- {type === 'generic' && ( - <> -
- + {/* ... Fields ... */} + {(type === 'generic' || type === 'custom') && ( +
+ +
setEndpoint(e.target.value)} /> - - Must return JSON. Include full URL with protocol and keys. - +
- -
- -
-
- Label - JSON Path - Suffix - Format - + + Must return JSON. Include full URL with protocol and keys. + + {testData && ( +
+
+ API Response Preview +
- {fields.map((field, i) => ( -
- handleFieldChange(i, 'label', e.target.value)} - /> - handleFieldChange(i, 'path', e.target.value)} - /> - handleFieldChange(i, 'suffix', e.target.value)} - /> - - -
- ))} - +
+                                                {testData}
+                                            
+ )} +
+ )} + + {type === 'generic' && ( +
+ +
+
+ Label + JSON Path + Suffix + Format + +
+ {fields.map((field, i) => ( +
+ handleFieldChange(i, 'label', e.target.value)} + /> + handleFieldChange(i, 'path', e.target.value)} + /> + handleFieldChange(i, 'suffix', e.target.value)} + /> + + +
+ ))} + +
+
+ )} + + {type === 'custom' && ( + <> +
+ +