feat: initial commit for auth-yes identity hub

This commit is contained in:
Tyler Gillispie 2026-08-21 14:11:06 -07:00
parent deb6d9309e
commit ac89e4c8d0
43 changed files with 9501 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
spire_ffi/target/
.env
.DS_Store
node_modules/

45
Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# Stage 1: Build the Rust FFI dynamic library
FROM rust:1-slim AS rust-builder
WORKDIR /usr/src/app
# Install protobuf compiler for tonic-build
RUN apt-get update && apt-get install -y protobuf-compiler
COPY spire_ffi ./spire_ffi
WORKDIR /usr/src/app/spire_ffi
RUN cargo build --release
# Stage 2: Cache Deno dependencies
FROM denoland/deno:debian-2.9.4 AS deno-builder
WORKDIR /app
ENV DENO_DIR=/deno-dir
# Layer cache: Pre-download dependencies (only invalidates if deps change)
COPY deno.json deno.lock* ./
COPY auth-yes/deno.json ./auth-yes/
COPY auth-yes/deps.ts ./auth-yes/
RUN deno cache auth-yes/deps.ts
# Source code layer
COPY . .
RUN deno cache auth-yes/server/main.ts
# Stage 3: Runner
FROM denoland/deno:debian-2.9.4
USER deno
WORKDIR /app
COPY --from=deno-builder --chown=deno:deno /deno-dir/ /deno-dir/
ENV DENO_DIR=/deno-dir
# Copy the Rust library
COPY --from=rust-builder --chown=deno:deno /usr/src/app/spire_ffi/target/release/libspire_ffi.so /app/libspire_ffi.so
COPY --from=deno-builder --chown=deno:deno /app/deno.json /app/deno.lock ./
COPY --from=deno-builder --chown=deno:deno /app/auth-yes/ ./auth-yes/
EXPOSE 8000
# Added --allow-ffi for Deno.dlopen and --allow-read for dlopen path resolution
CMD ["run", "--allow-net", "--allow-env", "--allow-ffi=./libspire_ffi.so", "--allow-read=.,/var/run/spire/agent.sock", "--unstable-ffi", "auth-yes/server/main.ts"]

455
README.md Normal file
View File

@ -0,0 +1,455 @@
# Custom IAM Architecture Analysis v2.md
# Architectural Blueprint & Enterprise Implementation Roadmap: Decoupled Zero-Trust Identity Fabric
## 1. Executive Summary & Paradigm Evolution
The **Auth-Yes** Identity Fabric represents an ultra-low-friction, zero-trust
Identity and Access Management (IAM) system tailored for a high-velocity
microservice ecosystem. By rejecting the protocol bloat, redirect friction, and
configuration overhead of traditional OIDC/OAuth2 monoliths (e.g., Keycloak,
Authentik, Okta), Auth-Yes delivers microsecond-level stateful session
verification, phishing-proof WebAuthn passkey authentication, and cryptographic
workload identity.
### Key Refinements in v2 Architecture:
1. **Parent-Domain WebAuthn Scoping (`RP_ID=atyg.org`):**
- Eliminates the runtime complexity of Related Origin Requests (ROR) for the
primary domain ecosystem. All subdomains across `*.atyg.org` natively share
passkeys and session verification.
2. **Three-Tier Defense-in-Depth Model:**
- **Tier 1 (Global Edge Default):** Universal Traefik ForwardAuth perimeter
fallback protecting untagged/pre-release services.
- **Tier 2 (Edge Proxy Override):** ForwardAuth cookie validation for
legacy/third-party applications (Portainer, Grafana, admin consoles).
- **Tier 3 (Application Zero-Trust):** In-app Deno App SDK communicating over
high-throughput ConnectRPC / gRPC with SPIFFE/SPIRE mTLS identity and
granular application RBAC.
3. **Structured Token Provisioning Taxonomy:**
- Formalized 3-tier invite token architecture (Global Admin, Site-Scoped, and
Open/Pending) alongside Out-of-Band Account Recovery.
4. **Complete Administrative Console Roadmap:**
- Finalized UI specifications for Application Registration (`/admin/apps`),
Multi-Type Invite Provisioning (`/admin/invites`), and Granular User RBAC
Grant Management (`/admin/users/:id`).
---
## 2. Core Security Architecture & Defense-in-Depth Layering
Auth-Yes implements a strict multi-layered defense model ensuring that neither
network locality nor perimeter isolation is treated as an implicit proxy for
trust.
```
[ Public Internet / Client Browser ]
═══════════════════════════════════════════════════
TIER 1 & 2: Traefik Reverse Proxy & ForwardAuth Edge
═══════════════════════════════════════════════════
│ │
(Legacy/3rd-Party Apps) (Native Microservices)
[ Portainer / Web UIs ] [ ed-droid Web Edge ]
│ │
▼ ▼
ForwardAuth Interception Traefik TLS Ingress
GET /api/forward-auth PassTLSClientCert
(Valkey Cache Lookup) │
│ ▼
└──────────────────────────────► ═══════════════════════════
TIER 3: Zero-Trust App Mesh
ConnectRPC + SPIFFE / mTLS
═══════════════════════════
[ Auth-Yes Core Gateway ]
- Valkey Cache (L1/L2)
- PostgreSQL Store
- Default-Deny RBAC
```
### 2.1. Tier 1 — Global Edge Perimeter (Traefik ForwardAuth Default Fallback)
- **Objective:** Ensure zero accidental exposure of internal or pre-release
services.
- **Mechanism:** Traefik entrypoints (e.g., `websecure`) are configured with a
default ForwardAuth middleware. Any newly created container or untagged
service deployed on the internal network is protected by default.
- **Behavior:** Unauthenticated HTTP requests without a valid session cookie are
automatically redirected to `https://auth.atyg.org/login`.
### 2.2. Tier 2 — Edge Proxy Override (Third-Party & Legacy Applications)
- **Objective:** Secure off-the-shelf and legacy software (e.g., Portainer,
Grafana, PgAdmin) without requiring code modifications or custom SDK
integration.
- **Mechanism:** Traefik routers for these services explicitly route
authentication checks to `https://auth.atyg.org/api/forward-auth`.
- **Validation Flow:**
1. Client sends request with `session_id` cookie scoped to `.atyg.org`.
2. Traefik queries `auth-api:8000/api/forward-auth`.
3. Auth API checks Valkey in microseconds. If valid and account is active,
returns HTTP `200 OK` and injects upstream identity headers:
- `X-Forwarded-User: <username>`
- `X-Forwarded-User-Id: <uuid>`
4. If invalid or missing, returns HTTP `401 Unauthorized` or redirects to
login.
### 2.3. Tier 3 — Application-Level Zero-Trust (Deno App SDK + ConnectRPC + SPIFFE/mTLS)
- **Objective:** Provide high-throughput, microsecond-latency identity
validation with granular Role-Based Access Control (RBAC) for native
microservices.
- **Mechanism:**
- Subsidiary services import the logic-pure `@ed-droid/auth-yes/sdk`
middleware.
- The SDK intercepts requests, extracts the session token, and validates it
against the Auth Hub using **ConnectRPC / gRPC** over HTTP/2 multiplexed
connections.
- **Workload Cryptographic Attestation:** Workload identity is authenticated
via **SPIFFE/SPIRE x509 SVID certificates**. The Auth Hub validates the
calling application's SPIFFE ID (e.g.,
`spiffe://system.local/ed-droid-backend`) against the `apps` table.
- **Default-Deny Authorization:** Authentication (who the user is) is
decoupled from authorization (what app they can access). The Auth Hub
verifies that an explicit active record exists in the `grants` table
matching `(user_id, app_id)`.
---
## 3. WebAuthn Scope Mechanics: Parent Domain vs. Related Origin Requests (ROR)
### 3.1. Parent Domain Scoping (`RP_ID=atyg.org`)
In the W3C WebAuthn specification, a credential is bound to a Relying Party
Identifier (`RP_ID`).
- Under WebAuthn origin validation rules, an `RP_ID` can be set to any
**registrable domain suffix (eTLD+1)** of the origin.
- By setting:
```env
RP_ID=atyg.org
ORIGIN=https://auth.atyg.org
```
- **Result:** Any passkey created under `RP_ID=atyg.org` is cryptographically
valid and authenticatable across **all** subdomains under `*.atyg.org` (e.g.,
`auth.atyg.org`, `ed-droid.atyg.org`, `nas.atyg.org`).
- **Browser Compliance:** Supported natively across 100% of modern WebAuthn
implementations (Apple Safari/iOS, Google Chrome/Android, Windows Hello,
1Password, YubiKeys).
### 3.2. Session Cookie Cross-Subdomain Sharing
To support edge proxy interception (ForwardAuth Tier 1 & 2), the Auth Hub sets
the session cookie with domain wildcard scoping:
```ts
setCookie(c, "session_id", sessionId, {
domain: ".atyg.org",
httpOnly: true,
secure: true,
sameSite: "Lax",
expires: expiresAt,
});
```
This ensures the browser transmits the session cookie seamlessly to all
subdomains under `*.atyg.org`.
### 3.3. Future Scope: Related Origin Requests (ROR)
Dynamic Related Origin Requests via `https://auth.atyg.org/.well-known/webauthn`
are officially archived for **Phase 3 (Cross-TLD Expansion)**. ROR will only be
required when federating authentication across completely distinct root domains
(e.g., bridging `atyg.org` with `ed-droid.io` or `independent-domain.com`).
---
## 4. Identity Lifecycle & Token Provisioning Taxonomy
Auth-Yes implements a strict 3-tier invite provisioning architecture ensuring
mathematical control over user onboarding and access grants.
```
┌────────────────────────────────────────┐
│ Admin Onboarding Generator │
└───────────────────┬────────────────────┘
┌───────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Type 1: Global │ │ Type 2: Site │ │ Type 3: General │
│ Admin Invite │ │ Scoped Invite │ │ Open / Pending │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ app_id: NULL │ │ app_id: UUID │ │ app_id: NULL │
│ role: 'admin' │ │ role: 'user/adm' │ │ role: 'user' │
│ status: 'active' │ │ status: 'active' │ │ status: 'pending'│
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### 4.1. Type 1: Global Admin Onboarding Token
- **Purpose:** Initial system bootstrapping and administrative team onboarding.
- **Characteristics:** `app_id` is unbound (`NULL` or Management Console),
`role = 'admin'`.
- **Lifecycle:** User registers passkey $\rightarrow$ Account is set to `active`
$\rightarrow$ User is granted global administrative privileges across Auth-Yes
and all internal systems.
### 4.2. Type 2: Site-Scoped Onboarding Token
- **Purpose:** Controlled user onboarding for a specific subsidiary application
(e.g., `ed-droid`).
- **Characteristics:** `app_id` is locked to a specific application UUID,
`role = 'user'` (or application admin).
- **Lifecycle:** User registers passkey $\rightarrow$ Account is set to `active`
$\rightarrow$ System automatically inserts a grant record into
`grants (user_id, app_id, role)`. User can access the designated application
immediately, but is denied access to all other apps.
### 4.3. Type 3: General Open / Pending Registration Token
- **Purpose:** Broad community or team registration requiring manual
verification.
- **Characteristics:** `app_id: NULL`, `role = 'user'`, initial
`account_status = 'pending'`.
- **Lifecycle:** User registers passkey $\rightarrow$ Account is created in
`pending` state $\rightarrow$ User cannot log in until a Global Admin accesses
`/admin/users` and activates the account, optionally assigning specific
application grants.
### 4.4. Out-of-Band Single-Use Account Recovery Token
- **Purpose:** Lockout recovery when a user loses all registered hardware
passkeys.
- **Characteristics:** 24-hour time-limited cryptographic token linked to
existing `user_id`.
- **Lifecycle:** Admin generates recovery link in `/admin/users/:id`
$\rightarrow$ Transmitted out-of-band $\rightarrow$ User accesses
`/recovery?code=...` $\rightarrow$ User binds new hardware passkey
$\rightarrow$ Previous passkeys invalidated, existing UUID and application
grants preserved.
### 4.5. The RBAC Grant Lifecycle: Default-Deny, Assignment Matrix & RPC Payload
The identity fabric enforces strict decoupled authorization governed by three
continuous states:
1. **The Baseline (Default-Deny Zero-Trust):**
- When a user registers a passkey or when an admin provisions an account, the
user possesses **zero application access by default**.
- If an unassigned user attempts to authenticate to `ed-droid.atyg.org`, the
calling application SDK queries the central `AuthService.validateSession`
endpoint over ConnectRPC.
- The Auth Hub queries
`SELECT role FROM grants WHERE user_id = $1 AND app_id = $2`. Finding no
matching grant record, the server returns
`{ valid: false, uuid: "", scopes: [], error: "Validation failed" }`,
resulting in an instant `403 Forbidden: No explicitly granted roles`.
2. **The Assignment Matrix (Admin Console):**
- In `https://auth.atyg.org/admin`:
- Under **Applications** (`/admin/apps`), apps are registered (e.g.
`ed-droid`, SPIFFE ID `spiffe://system.local/ed-droid-backend`).
- Under **User Profile** (`/admin/users/:id`), the administrator views the
user's active permissions matrix.
- To grant access to a user (e.g., your nephew), the administrator selects
`ed-droid` from the app selector, selects a role (`viewer`, `operator`,
`editor`, `admin`), and clicks **Grant Access** (or checks the matrix
toggle).
- An atomic row is written to `grants (user_id, app_id, role)`.
3. **The Payload & Runtime Authorization:**
- The next time the user makes a request to `ed-droid`, the Deno App SDK
intercepts the session token and validates it via ConnectRPC over internal
mTLS.
- The Auth Hub validates the session in Valkey, retrieves the active grant
for `ed-droid`, and returns the cryptographic payload:
```json
{
"valid": true,
"uuid": "edfa8a54-66c5-4444-9e38-becfc5aa6463",
"scopes": ["viewer"]
}
```
- The Deno App SDK middleware injects
`c.set("user", { uuid, scopes: ["viewer"] })` into the application context,
allowing `ed-droid` to render the exact UI views and API actions permitted
for a `viewer`.
---
## 5. PostgreSQL Central Identity Schema Reference
```sql
-- Central Users Table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
account_status TEXT DEFAULT 'pending' CHECK (account_status IN ('active', 'pending', 'suspended')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Registered Applications / Sites Table
CREATE TABLE IF NOT EXISTS apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
spiffe_id TEXT UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Granular Application RBAC Grants Table
CREATE TABLE IF NOT EXISTS grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, app_id)
);
-- Onboarding & Invitation Tokens Table
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
used_at TIMESTAMPTZ
);
-- Hardware Passkeys Table
CREATE TABLE IF NOT EXISTS passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
aaguid TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Active User Sessions Table
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Security Audit Records Table
CREATE TABLE IF NOT EXISTS audit_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enterprise Hardware AAGUID Allowlist Table
CREATE TABLE IF NOT EXISTS aaguid_allowlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aaguid TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 6. Administrative UI Implementation Specifications
The Auth-Yes Admin Console is built using server-rendered Deno Hono JSX
components executing direct database queries with zero intermediate loopback
latency.
### 6.1. Navigation Architecture (`AuthenticatedLayout.tsx` & `AdminLayout.tsx`)
```
[ Identity Provider Navbar ]
├── User Dashboard (/dashboard)
├── Active Sessions (/dashboard/sessions)
├── Registered Passkeys (/dashboard/passkeys)
└── [ ADMIN CONSOLE ] (Rendered if isAdmin = true)
├── User Directory (/admin/users)
├── Application Registry (/admin/apps)
├── Invite Tokens (/admin/invites)
├── AAGUID Allow-List (/admin/aaguid)
└── Security Audit Logs (/admin/audit-logs)
```
### 6.2. Page Specifications & Actions
#### 1. User Directory (`/admin/users`)
- **Features:**
- Tabular list of all registered users (`username`, `display_name`,
`account_status`, `created_at`, `grant_count`).
- Status mutation buttons: _Activate_, _Suspend_, _Re-Activate_.
- Link to detailed User Management profile (`/admin/users/:id`).
#### 2. User Profile & RBAC Grant Manager (`/admin/users/:id`)
- **Features:**
- **Account Recovery:** Single-click _Generate 24h Out-of-Band Recovery Link_.
- **Active Sessions:** List active sessions with individual and _Revoke All_
actions.
- **Registered Hardware Tokens:** List registered passkeys (`credential_id`,
`counter`, `created_at`) with _Delete Device_ action.
- **Application Access Grants (New in v2):**
- Matrix of currently assigned application access (`App Name`, `Role`,
`Granted At`).
- _Grant Access_ dropdown selector (`App`, `Role: user | admin`) to attach
new app permissions.
- _Revoke Access_ button to remove application-specific access.
#### 3. Application Registry (`/admin/apps`)
- **Features:**
- Table of all registered internal applications (`name`, `spiffe_id`,
`description`, `active_users_count`).
- _Register New Application_ form:
- `App Name` (e.g., "Elite Dangerous Streaming Hub")
- `SPIFFE ID` (e.g., `spiffe://system.local/ed-droid-backend`)
- `Description`
- _Delete Application_ action (with confirmation modal preventing accidental
lockout).
#### 4. Invite & Onboarding Token Manager (`/admin/invites`)
- **Features:**
- **Token Generation Modal/Form:**
- **Token Type Selector:**
1. _Global Admin Token_ (Pre-configures `role: 'admin'`, `app: NULL`)
2. _Site-Scoped Token_ (Displays App dropdown selector + Role selector)
3. _Open/Pending User Token_ (Pre-configures `role: 'user'`, `app: NULL`,
creates pending user)
- **Expiration Bounds:** 1 to 30 days (default: 7 days).
- **Custom Invite Code (Optional):** Auto-generates cryptographically random
code if left blank.
- **Live Invites Ledger:**
- Table displaying `Code`, `Type / Scope`, `Target App`, `Role`,
`Expires At`, `Status (Active / Used / Expired)`.
- Single-click _Copy Registration URL_ button
(`https://auth.atyg.org/register?code=...`).
- _Revoke Invite_ action.
---
## 7. Implementation Roadmap & Execution Checklist
| Phase | Milestone | Scope / Deliverables | Status |
| :---------- | :----------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------ |
| **Phase 1** | **Core Identity & Passkey Engine** | - WebAuthn challenge & verification engine<br>- PostgreSQL schema auto-initialization<br>- Valkey session cache & instant revocation<br>- Direct SSR database rendering (zero-loopback)<br>- Hybrid hardware/software passkey support | **COMPLETED** |
| **Phase 2** | **Zero-Trust App Mesh (Backend)** | - ConnectRPC / gRPC transport<br>- SPIFFE/SPIRE mTLS client certificate validation<br>- Default-deny RBAC grant verification<br>- ForwardAuth `/api/forward-auth` endpoint | **COMPLETED** |
| **Phase 3** | **Admin Console UI Views (Current)** | - `/admin/apps` Application Registry UI<br>- `/admin/invites` Multi-Type Token Provisioning UI<br>- `/admin/users/:id` App RBAC Grant Manager UI<br>- Domain wildcard cookie (`.atyg.org`) deployment | **READY FOR BUILD** |
| **Phase 4** | **Edge Hardening & Future Scoping** | - Traefik Tier 1 global ForwardAuth fallback configuration<br>- Traefik Tier 2 ForwardAuth container routing<br>- Related Origin Requests (`/.well-known/webauthn`) for external TLDs | **FUTURE** |

35
deno.json Normal file
View File

@ -0,0 +1,35 @@
{
"workspace": [
"./sdk",
"./server",
"./ui"
],
"tasks": {
"lint": "deno lint",
"fmt": "deno fmt",
"check": "deno check server/**/*.ts sdk/**/*.ts ui/**/*.ts infra/**/*.ts",
"test": "deno test -A",
"setup": "deno run -A infra/setup.ts"
},
"lint": {
"rules": {
"exclude": [
"no-empty",
"no-import-prefix",
"no-unversioned-import",
"no-explicit-any",
"require-await"
]
}
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "jsr:@hono/hono@4/jsx"
},
"imports": {
"@bufbuild/protobuf": "npm:@bufbuild/protobuf@^1.10.0",
"@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.7",
"@connectrpc/connect": "npm:@connectrpc/connect@^1.4.0",
"@connectrpc/connect-node": "npm:@connectrpc/connect-node@^1.4.0"
}
}

635
infra/setup.ts Normal file
View File

@ -0,0 +1,635 @@
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
import * as colors from "jsr:@std/fmt@0.225.2/colors";
import * as path from "jsr:@std/path@0.225.2";
const ENV_PATH = path.join("infra", ".env");
const COMPOSE_PATH = path.join("infra", "compose.yml");
const SPIRE_COMPOSE_PATH = path.join("infra", "compose.spire.yml");
export interface AuthSetupConfig {
reg: string;
domainName: string;
dbPassword: string;
dbDataPath: string;
appSecret: string;
}
const DEFAULT_AUTH_CONFIG: AuthSetupConfig = {
reg: "quay.atyg.org",
domainName: "auth.system.local",
dbPassword: "",
dbDataPath: "/volume1/docker/auth-yes/db",
appSecret: "",
};
export async function readEnv(): Promise<Partial<AuthSetupConfig>> {
try {
const text = await Deno.readTextFile(ENV_PATH);
const config: Partial<AuthSetupConfig> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [key, ...rest] = trimmed.split("=");
const val = rest.join("=").trim();
if (key === "REG") config.reg = val;
if (key === "SYSTEM_DOMAIN") config.domainName = val;
if (key === "POSTGRES_PASSWORD") config.dbPassword = val;
if (key === "DB_DATA_PATH") config.dbDataPath = val;
if (key === "APP_SECRET") config.appSecret = val;
}
return config;
} catch (_e) {
// Ignore and check next
return {};
}
}
export function generateEnv(config: AuthSetupConfig): string {
return `# --- Container Registry ---
REG=${config.reg}
# --- Network & Routing ---
SYSTEM_DOMAIN=${config.domainName}
RP_ID=${config.domainName}
ORIGIN=https://${config.domainName}
# --- Identity Provider Internal App Secret ---
APP_SECRET=${config.appSecret}
# --- Database Configuration ---
POSTGRES_HOST=auth-db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_DB=authdb
POSTGRES_PASSWORD=${config.dbPassword}
DB_DATA_PATH=${config.dbDataPath}
# --- Valkey Configuration ---
VALKEY_HOST=auth-valkey
VALKEY_PORT=6379
VALKEY_URL=redis://auth-valkey:6379
`;
}
export function generateDockerCompose(): string {
return `version: "3.8"
services:
auth-api:
image: \${REG}/library/auth-yes-api:latest
env_file: stack.env
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.auth-api.rule=Host(\`\${SYSTEM_DOMAIN}\`)"
- "traefik.http.routers.auth-api.entrypoints=websecure"
- "traefik.http.routers.auth-api.tls=true"
- "traefik.http.services.auth-api.loadbalancer.server.port=8000"
expose:
- "8000"
depends_on:
- auth-db
- auth-valkey
networks:
- default
- traefik-net
volumes:
- spire-socket:/var/run/spire:ro
auth-db:
image: acr.atyg.org/library/postgres:18-alpine
environment:
- POSTGRES_USER=\${POSTGRES_USER}
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
- POSTGRES_DB=\${POSTGRES_DB}
volumes:
- auth-db-data:/var/lib/postgresql
networks:
- default
auth-valkey:
image: acr.atyg.org/valkey/valkey:8-alpine
networks:
- default
volumes:
auth-db-data:
driver: local
driver_opts:
type: none
device: \${DB_DATA_PATH}
o: bind
spire-socket:
name: spire-socket
networks:
default:
name: auth-internal-net
traefik-net:
external: true
`;
}
export function generateSpireDockerCompose(): string {
return `version: "3.8"
services:
spire-server:
image: gcr.io/spiffe-io/spire-server:1.9.3
container_name: spire-server
hostname: spire-server
networks:
- auth-internal-net
volumes:
- ./spire/server/data:/opt/spire/data
- ./spire/server/conf/server.conf:/opt/spire/conf/server.conf:ro
command: ["-config", "/opt/spire/conf/server.conf"]
spire-agent:
image: gcr.io/spiffe-io/spire-agent:1.9.3
container_name: spire-agent
hostname: spire-agent
pid: host
networks:
- auth-internal-net
volumes:
- spire-socket:/var/run/spire
- ./spire/agent/data:/opt/spire/data
- ./spire/agent/conf/agent.conf:/opt/spire/conf/agent.conf:ro
command: ["-config", "/opt/spire/conf/agent.conf"]
depends_on:
- spire-server
volumes:
spire-socket:
name: spire-socket
networks:
auth-internal-net:
external: true
`;
}
export function generateProtobufCompilationCommands(): string[] {
return [
"deno",
"run",
"-A",
"npm:@bufbuild/buf",
"generate",
"server/auth.proto",
"--template",
'{"version":"v1","plugins":[{"plugin":"buf.build/bufbuild/es:v1.10.0","out":"server/gen","opt":"target=ts,import_extension=.ts"},{"plugin":"buf.build/connectrpc/es:v1.4.0","out":"server/gen","opt":"target=ts,import_extension=.ts"}]}',
];
}
// SIDE EFFECT: Runs the protoc compilation
export async function executeProtobufCompilation(): Promise<void> {
const commands = generateProtobufCompilationCommands();
const cmd = new Deno.Command(commands[0], {
args: commands.slice(1),
stdout: "inherit",
stderr: "inherit",
});
const { code } = await cmd.output();
if (code !== 0) {
throw new Error("Failed to compile protobuf definitions.");
}
}
export async function downloadWorkloadProto(): Promise<void> {
console.log(colors.blue("\nDownloading workload.proto..."));
const res = await fetch(
"https://raw.githubusercontent.com/spiffe/go-spiffe/main/proto/spiffe/workload/workload.proto",
);
if (!res.ok) {
throw new Error(`Failed to download workload.proto: ${res.statusText}`);
}
const text = await res.text();
await Deno.mkdir("../spire_ffi/proto", { recursive: true });
await Deno.writeTextFile("../spire_ffi/proto/workload.proto", text);
console.log(colors.green("✓ workload.proto downloaded successfully."));
}
export function generateBuildCommands(reg: string): string[] {
return [
`podman build -t ${reg}/library/auth-yes-api:latest -f Dockerfile ..`,
`podman push ${reg}/library/auth-yes-api:latest`,
];
}
// SIDE EFFECT: Executes docker build and push commands to the host system
export async function executeBuildImage(commands: string[]): Promise<void> {
for (const cmd of commands) {
console.log(colors.cyan(`\nExecuting: ${cmd}`));
const args = cmd.split(" ");
const process = new Deno.Command(args[0], {
args: args.slice(1),
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
});
const { code } = await process.output();
if (code !== 0) {
throw new Error(`Command failed with exit code ${code}: ${cmd}`);
}
}
}
export async function generateAuthSetupFiles(
config: AuthSetupConfig,
): Promise<void> {
const envContent = generateEnv(config);
await Deno.writeTextFile(ENV_PATH, envContent);
const composeContent = generateDockerCompose();
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
const spireComposeContent = generateSpireDockerCompose();
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
console.log(
colors.green(
`\n✓ Successfully generated ${ENV_PATH} and ${COMPOSE_PATH}!`,
),
);
console.log(
colors.green("Setup complete. You may now deploy your stack by running:\n"),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
),
);
}
export async function handleAuthSetup(
currentConfig: AuthSetupConfig,
): Promise<AuthSetupConfig> {
console.log(
colors.gray(
"Please provide the following Auth Yes configuration details.\n",
),
);
const reg = await Input.prompt({
message: "Enter the Container Registry URL:",
default: currentConfig.reg,
});
const domainName = await Input.prompt({
message: "Enter the Auth Domain Name:",
hint: "E.g., auth.system.local",
default: currentConfig.domainName,
});
const dbDataPath = await Input.prompt({
message: "Enter the Database Path on the Host:",
default: currentConfig.dbDataPath,
});
const appSecret = await Secret.prompt({
message: "Enter the App Secret for the IDP:",
default: currentConfig.appSecret,
minLength: 16,
});
const pwdMessage = currentConfig.dbPassword
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
: "Enter the PostgreSQL database password:";
const pwdInput = await Secret.prompt({
message: pwdMessage,
minLength: currentConfig.dbPassword ? 0 : 1,
});
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
? currentConfig.dbPassword
: pwdInput;
const newConfig: AuthSetupConfig = {
reg,
domainName,
dbPassword,
dbDataPath,
appSecret,
};
await generateAuthSetupFiles(newConfig);
return newConfig;
}
export async function handleTestConnection(domainName: string): Promise<void> {
console.log(
colors.bold(
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
),
);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const res1 = await fetch(`https://${domainName}/`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
throw new Error(`Gateway error: ${res1.status}`);
}
if (res1.body) {
await res1.body.cancel();
}
console.log(
colors.green(
`✓ Auth service is up and reachable at https://${domainName}/`,
),
);
} catch (error) {
console.log(
colors.red(
`✗ Error: Could not reach the auth service at https://${domainName}/`,
),
);
if (error instanceof Error) {
console.log(colors.red(` Reason: ${error.message}`));
} else {
console.log(colors.red(` Reason: ${error}`));
}
console.log(
colors.red(
" Please verify the container is running and DNS/Traefik is resolving.",
),
);
}
}
async function handleReviewConfigs(): Promise<void> {
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
null
);
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
() => null,
);
if (!envContent && !composeContent && !spireComposeContent) {
console.log(
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
);
return;
}
while (true) {
const action = await Select.prompt({
message: "Review Generated Configs",
options: [
{ name: "[Show .env]", value: "env" },
{ name: "[Show compose.yml]", value: "compose" },
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
{ name: "[Back to Main Menu]", value: "back" },
],
});
if (action === "env") {
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
console.log(envContent || colors.yellow("File not found."));
console.log();
} else if (action === "compose") {
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
console.log(composeContent || colors.yellow("File not found."));
console.log();
} else if (action === "spire_compose") {
console.log(
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
);
console.log(spireComposeContent || colors.yellow("File not found."));
console.log();
} else if (action === "back") {
break;
}
}
}
// SIDE EFFECT: Runs the interactive CLI wizard.
export async function runSetupWizard(): Promise<void> {
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
const loadedEnv = await readEnv();
let currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
await handleTestConnection(currentConfig.domainName);
console.log();
}
while (true) {
const action = await Select.prompt({
message: "Main Menu",
options: [
{ name: "[Test Auth Connection]", value: "test" },
{ name: "[Configure Auth Yes API]", value: "auth" },
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
{ name: "[Review Generated Configs]", value: "review" },
{ name: "[Build and Push Auth Image]", value: "build" },
{ name: "[Exit]", value: "exit" },
],
});
if (action === "test") {
await handleTestConnection(currentConfig.domainName);
console.log();
} else if (action === "auth") {
currentConfig = await handleAuthSetup(currentConfig);
} else if (action === "compile_proto") {
try {
await downloadWorkloadProto();
await executeProtobufCompilation();
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
);
} else {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
);
}
}
} else if (action === "review") {
await handleReviewConfigs();
} else if (action === "build") {
try {
const commands = generateBuildCommands(currentConfig.reg);
await executeBuildImage(commands);
console.log(colors.green("\n✓ Successfully built and pushed image!"));
console.log(
colors.green("You may now deploy your stack by running:\n"),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
),
);
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Build process failed: ${error.message}\n`),
);
} else {
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
}
}
} else if (action === "exit") {
console.log(colors.gray("Exiting...\n"));
break;
}
}
}
if (import.meta.main) {
if (!Deno.stdin.isTerminal() && Deno.args.length === 0) {
console.error(
colors.red(
"Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.",
),
);
Deno.exit(1);
}
const cmd = new Command()
.name("auth-setup")
.description("Auth setup wizard and CLI")
.action(() => {
runSetupWizard();
})
.command("auth", "Configure Auth Yes API")
.option("--auto, --headless", "Run in headless mode")
.option("--registry <reg:string>", "Container Registry URL")
.option("--domain <domain:string>", "Auth Domain Name")
.option("--db-path <path:string>", "Database Path on the Host")
.action(async (options) => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
if (options.auto) {
if (!options.registry || !options.domain || !options.dbPath) {
console.error(
colors.red(
"Error: --registry, --domain, and --db-path are required in headless mode.",
),
);
Deno.exit(1);
}
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
currentConfig.dbPassword;
const appSecret = Deno.env.get("APP_SECRET") ||
currentConfig.appSecret;
if (!dbPassword) {
console.error(
colors.red(
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
),
);
Deno.exit(1);
}
if (!appSecret) {
console.error(
colors.red(
"Error: APP_SECRET environment variable is required in headless mode.",
),
);
Deno.exit(1);
}
const newConfig: AuthSetupConfig = {
reg: options.registry,
domainName: options.domain,
dbPassword,
dbDataPath: options.dbPath,
appSecret,
};
await generateAuthSetupFiles(newConfig);
} else {
if (options.registry) currentConfig.reg = options.registry;
if (options.domain) currentConfig.domainName = options.domain;
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
await handleAuthSetup(currentConfig);
}
})
.command("test", "Test Auth Connection")
.action(async () => {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
await handleTestConnection(currentConfig.domainName);
})
.command("compile_proto", "Compile Protobuf Definitions")
.action(async () => {
try {
await downloadWorkloadProto();
await executeProtobufCompilation();
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
);
} else {
console.log(
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
);
}
Deno.exit(1);
}
})
.command("build", "Build and Push Auth Image")
.action(async () => {
try {
const loadedEnv = await readEnv();
const currentConfig: AuthSetupConfig = {
...DEFAULT_AUTH_CONFIG,
...loadedEnv,
};
const commands = generateBuildCommands(currentConfig.reg);
await executeBuildImage(commands);
console.log(
colors.green("\n✓ Successfully built and pushed auth image!"),
);
console.log(
colors.green("You may now deploy your stack by running:\n"),
);
console.log(
colors.cyan(
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
),
);
} catch (error) {
if (error instanceof Error) {
console.log(
colors.red(`\n✗ Build process failed: ${error.message}\n`),
);
} else {
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
}
Deno.exit(1);
}
});
await cmd.parse(Deno.args);
}

5
sdk/deno.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "@auth-yes/sdk",
"version": "0.1.0",
"exports": "./mod.ts"
}

27
sdk/gen/auth_connect.ts Normal file
View File

@ -0,0 +1,27 @@
// @generated by protoc-gen-connect-es v1.4.0 with parameter "target=ts,import_extension=.ts"
// @generated from file auth.proto (package auth.v1, syntax proto3)
/* eslint-disable */
// @ts-nocheck: Generated protobuf connect code
import { ValidateSessionRequest, ValidateSessionResponse } from "./auth_pb.ts";
import { MethodKind } from "npm:@bufbuild/protobuf@^1.10.0";
/**
* @generated from service auth.v1.AuthService
*/
export const AuthService = {
typeName: "auth.v1.AuthService",
methods: {
/**
* Validates a session token
*
* @generated from rpc auth.v1.AuthService.ValidateSession
*/
validateSession: {
name: "ValidateSession",
I: ValidateSessionRequest,
O: ValidateSessionResponse,
kind: MethodKind.Unary,
},
},
} as const;

148
sdk/gen/auth_pb.ts Normal file
View File

@ -0,0 +1,148 @@
// @generated by protoc-gen-es v1.10.0 with parameter "target=ts,import_extension=.ts"
// @generated from file auth.proto (package auth.v1, syntax proto3)
/* eslint-disable */
// @ts-nocheck: Generated protobuf schema code
import type {
BinaryReadOptions,
FieldList,
JsonReadOptions,
JsonValue,
PartialMessage,
PlainMessage,
} from "npm:@bufbuild/protobuf@^1.10.0";
import { Message, proto3 } from "npm:@bufbuild/protobuf@^1.10.0";
/**
* @generated from message auth.v1.ValidateSessionRequest
*/
export class ValidateSessionRequest extends Message<ValidateSessionRequest> {
/**
* @generated from field: string token = 1;
*/
token = "";
constructor(data?: PartialMessage<ValidateSessionRequest>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "auth.v1.ValidateSessionRequest";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "token", kind: "scalar", T: 9 /* ScalarType.STRING */ },
]);
static fromBinary(
bytes: Uint8Array,
options?: Partial<BinaryReadOptions>,
): ValidateSessionRequest {
return new ValidateSessionRequest().fromBinary(bytes, options);
}
static fromJson(
jsonValue: JsonValue,
options?: Partial<JsonReadOptions>,
): ValidateSessionRequest {
return new ValidateSessionRequest().fromJson(jsonValue, options);
}
static fromJsonString(
jsonString: string,
options?: Partial<JsonReadOptions>,
): ValidateSessionRequest {
return new ValidateSessionRequest().fromJsonString(jsonString, options);
}
static equals(
a:
| ValidateSessionRequest
| PlainMessage<ValidateSessionRequest>
| undefined,
b:
| ValidateSessionRequest
| PlainMessage<ValidateSessionRequest>
| undefined,
): boolean {
return proto3.util.equals(ValidateSessionRequest, a, b);
}
}
/**
* @generated from message auth.v1.ValidateSessionResponse
*/
export class ValidateSessionResponse extends Message<ValidateSessionResponse> {
/**
* @generated from field: bool valid = 1;
*/
valid = false;
/**
* @generated from field: string uuid = 2;
*/
uuid = "";
/**
* @generated from field: repeated string scopes = 3;
*/
scopes: string[] = [];
/**
* @generated from field: string error = 4;
*/
error = "";
constructor(data?: PartialMessage<ValidateSessionResponse>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "auth.v1.ValidateSessionResponse";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "valid", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
{ no: 2, name: "uuid", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{
no: 3,
name: "scopes",
kind: "scalar",
T: 9, /* ScalarType.STRING */
repeated: true,
},
{ no: 4, name: "error", kind: "scalar", T: 9 /* ScalarType.STRING */ },
]);
static fromBinary(
bytes: Uint8Array,
options?: Partial<BinaryReadOptions>,
): ValidateSessionResponse {
return new ValidateSessionResponse().fromBinary(bytes, options);
}
static fromJson(
jsonValue: JsonValue,
options?: Partial<JsonReadOptions>,
): ValidateSessionResponse {
return new ValidateSessionResponse().fromJson(jsonValue, options);
}
static fromJsonString(
jsonString: string,
options?: Partial<JsonReadOptions>,
): ValidateSessionResponse {
return new ValidateSessionResponse().fromJsonString(jsonString, options);
}
static equals(
a:
| ValidateSessionResponse
| PlainMessage<ValidateSessionResponse>
| undefined,
b:
| ValidateSessionResponse
| PlainMessage<ValidateSessionResponse>
| undefined,
): boolean {
return proto3.util.equals(ValidateSessionResponse, a, b);
}
}

10
sdk/mod.test.ts Normal file
View File

@ -0,0 +1,10 @@
import { assertEquals } from "jsr:@std/assert";
import { createAuthSdk } from "./mod.ts";
Deno.test("AuthSdk - initializes with config", () => {
const sdk = createAuthSdk({
authApiUrl: "http://localhost:8000",
});
assertEquals(typeof sdk.validateSession, "function");
assertEquals(typeof sdk.requireAuth, "function");
});

204
sdk/mod.ts Normal file
View File

@ -0,0 +1,204 @@
// SDK Client for Auth-Yes Zero Trust Identity Provider
// Designed to be imported by subsidiary applications to validate stateful session tokens.
import { Redis } from "npm:ioredis";
import { createClient } from "npm:@connectrpc/connect@^1.4.0";
import { createConnectTransport } from "npm:@connectrpc/connect-node@^1.4.0";
import { AuthService } from "./gen/auth_connect.ts";
/**
* Configuration options for the Auth SDK.
*/
export interface AuthSdkConfig {
/**
* The internal network URL of the Auth API Gateway.
* e.g., 'http://auth-api.internal:8000'
*/
authApiUrl: string;
/**
* The Valkey URL for RESP3 Client-Side Caching (optional).
* e.g., 'redis://auth-valkey:6379'
*/
valkeyUrl?: string;
/**
* Optional custom transport if deploying in environments (like browser)
* where connect-node is unavailable.
*/
customTransport?: any;
/**
* The mTLS certificate.
*/
tlsCert?: string;
/**
* The mTLS private key.
*/
tlsKey?: string;
/**
* The mTLS CA certificate.
*/
tlsCa?: string;
}
/**
* The validated session data returned by the Auth API.
*/
export interface SessionData {
valid: boolean;
uuid?: string;
scopes?: string[];
error?: string;
}
export class AuthSdk {
private config: AuthSdkConfig;
private l1Cache: Map<string, SessionData>;
private valkeyClient: Redis | null = null;
private grpcClient: any;
constructor(config: AuthSdkConfig) {
this.config = config;
this.l1Cache = new Map();
if (this.config.valkeyUrl) {
this.initValkeyClient();
}
const nodeOptions: Record<string, any> = { rejectUnauthorized: false };
if (this.config.tlsCert && this.config.tlsKey && this.config.tlsCa) {
nodeOptions.rejectUnauthorized = true;
nodeOptions.cert = this.config.tlsCert;
nodeOptions.key = this.config.tlsKey;
nodeOptions.ca = this.config.tlsCa;
}
const transport = this.config.customTransport || createConnectTransport({
baseUrl: this.config.authApiUrl,
httpVersion: "2",
nodeOptions: nodeOptions,
});
this.grpcClient = createClient(AuthService, transport);
}
private initValkeyClient() {
this.valkeyClient = new Redis(this.config.valkeyUrl!, {
enableOfflineQueue: false,
});
this.valkeyClient.on("ready", async () => {
// Negotiate RESP3 and enable client tracking
try {
await this.valkeyClient!.hello(3);
// Enable tracking in BCAST (broadcast) mode because this client
// never actually issues GET commands to trigger standard tracking
await this.valkeyClient!.client("TRACKING", "ON", "BCAST");
} catch (e) {
console.error("Failed to enable RESP3 client tracking:", e);
}
});
// Listen for RESP3 push invalidation messages
this.valkeyClient.on("push", (msg: unknown) => {
if (
Array.isArray(msg) && msg.length >= 2 && msg[0] === "invalidate"
) {
const keysToInvalidate = msg[1];
if (Array.isArray(keysToInvalidate)) {
for (const key of keysToInvalidate) {
// SIDE EFFECT: Delete the invalidated key from the local Map
this.l1Cache.delete(key);
}
}
}
});
this.valkeyClient.on("error", (err: unknown) => {
console.error("Valkey SDK Client error:", err);
console.warn(
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
);
this.l1Cache.clear();
});
this.valkeyClient.on("close", () => {
console.warn(
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
);
this.l1Cache.clear();
});
this.valkeyClient.on("end", () => {
console.warn(
"[AuthSdk] Valkey connection lost. Clearing L1 cache to prevent stale sessions.",
);
this.l1Cache.clear();
});
}
/**
* Validates an opaque session token against the Auth API.
* This is a fast-path operation that leverages the central Valkey cache.
*
* @param token The opaque session token (e.g., extracted from a cookie).
* @returns The validated session data containing the UUID and scopes.
*/
async validateSession(token: string): Promise<SessionData> {
// Check L1 cache first
const cachedSession = this.l1Cache.get(token);
if (cachedSession) {
return cachedSession;
}
try {
const response = await this.grpcClient.validateSession({ token });
const sessionData: SessionData = {
valid: response.valid,
uuid: response.uuid,
scopes: response.scopes,
error: response.error,
};
// Only populate L1 cache if Valkey integration is enabled for invalidations
if (sessionData.valid && this.config.valkeyUrl) {
this.l1Cache.set(token, sessionData);
}
return sessionData;
} catch (error) {
// Typically network errors or internal DNS resolution failures
return {
valid: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Middleware for web frameworks (e.g., Oak, Hono) to intercept and validate requests.
* Developers should wrap this around protected routes.
*
* @param token The extracted session token.
* @throws Error if the token is invalid or missing.
* @returns The user's UUID.
*/
async requireAuth(token: string | null | undefined): Promise<string> {
if (!token) {
throw new Error("Unauthorized: Missing session token.");
}
const session = await this.validateSession(token);
if (!session.valid || !session.uuid) {
throw new Error(`Unauthorized: ${session.error || "Invalid session."}`);
}
return session.uuid;
}
}
/**
* Creates a new instance of the Auth SDK.
*/
export function createAuthSdk(config: AuthSdkConfig): AuthSdk {
return new AuthSdk(config);
}

24
server/audit.ts Normal file
View File

@ -0,0 +1,24 @@
import { sql } from "./db.ts";
/**
* SIDE EFFECT: Asynchronously logs an audit record to the database.
* Does not block the main execution thread. Errors are logged but swallowed
* to prevent failing the core request due to a logging issue.
*/
export function auditLog(
userId: string | null,
action: string,
resource: string | null,
details: Record<string, unknown> | null,
ipAddress: string,
): void {
// Fire and forget
sql`
INSERT INTO audit_records (user_id, action, resource, details, ip_address)
VALUES (${userId}, ${action}, ${resource}, ${
details ? JSON.stringify(details) : null
}, ${ipAddress})
`.catch((error) => {
console.error("[Audit Logger] Failed to insert audit record:", error);
});
}

109
server/auth-session.ts Normal file
View File

@ -0,0 +1,109 @@
import type { Context } from "jsr:@hono/hono@4";
import { getCookie } from "jsr:@hono/hono@4/cookie";
import { sql } from "./db.ts";
import { valkey } from "./valkey.ts";
export interface AuthenticatedUser {
userId: string;
sessionId: string;
username: string;
}
/**
* Helper to get authenticated user from session cookie.
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
*/
export async function getAuthenticatedUser(
c: Context,
): Promise<AuthenticatedUser | null> {
const sessionId = getCookie(c, "session_id");
if (!sessionId) return null;
// 1. Try Valkey cache
try {
const sessionDataStr = await valkey.get(sessionId);
if (sessionDataStr) {
const sessionData = JSON.parse(sessionDataStr);
if (sessionData && sessionData.uuid) {
return {
userId: sessionData.uuid,
sessionId,
username: sessionData.username || "",
};
}
}
} catch (_err) {
// Valkey cache miss or connection hiccup - fallback to DB
}
// 2. Fallback to PostgreSQL sessions table
try {
const session = await sql`
SELECT s.user_id, s.expires_at, u.username
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${sessionId} AND s.expires_at > NOW()
`.then((res) => res[0]);
if (session) {
const username = session.username || "";
// Repopulate Valkey in background
try {
const ttlSeconds = Math.max(
1,
Math.floor(
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
),
);
await valkey.setex(
sessionId,
ttlSeconds,
JSON.stringify({ uuid: session.user_id, username }),
);
} catch (_e) {}
return { userId: session.user_id, sessionId, username };
}
} catch (_err) {
return null;
}
return null;
}
/**
* Helper to check if user has global admin privileges.
* Strict check: Requires an explicit 'admin' grant on the Management Console
* or global role, or is the bootstrap root user.
*/
export async function isGlobalAdmin(userId: string): Promise<boolean> {
try {
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
const adminGrant = await sql`
SELECT g.id
FROM grants g
LEFT JOIN apps a ON g.app_id = a.id
WHERE g.user_id = ${userId}
AND g.role = 'admin'
AND (
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
OR a.name = 'Auth-Yes Management Console'
OR g.app_id IS NULL
)
`.then((res) => res[0]);
if (adminGrant) return true;
// Check 2: First registered user in system fallback
const firstUser = await sql`
SELECT id FROM users ORDER BY created_at ASC LIMIT 1
`.then((res) => res[0]);
if (firstUser && firstUser.id === userId) {
return true;
}
} catch (err) {
console.error("[Auth API] isGlobalAdmin error:", err);
}
return false;
}

19
server/auth.proto Normal file
View File

@ -0,0 +1,19 @@
syntax = "proto3";
package auth.v1;
service AuthService {
// Validates a session token
rpc ValidateSession (ValidateSessionRequest) returns (ValidateSessionResponse) {}
}
message ValidateSessionRequest {
string token = 1;
}
message ValidateSessionResponse {
bool valid = 1;
string uuid = 2;
repeated string scopes = 3;
string error = 4;
}

202
server/db.ts Normal file
View File

@ -0,0 +1,202 @@
import postgres from "npm:postgres@3";
const host = Deno.env.get("POSTGRES_HOST");
const user = Deno.env.get("POSTGRES_USER");
const password = Deno.env.get("POSTGRES_PASSWORD");
const db = Deno.env.get("POSTGRES_DB");
const port = Deno.env.get("POSTGRES_PORT") || "5432";
if (!host || !user || !password || !db) {
throw new Error(
"Missing critical database environment variables. Required: POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB.",
);
}
const connectionString = `postgres://${user}:${password}@${host}:${port}/${db}`;
export const sql = postgres(connectionString);
/**
* SIDE EFFECT: Initializes the database schema.
*/
export async function initDb(): Promise<void> {
console.log("[Auth DB] Initializing central identity database schema...");
// We ensure new users are 'pending' to satisfy Use Case 3 (Manual state machine activation)
// If the table exists we will attempt to alter the default.
await sql`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE NOT NULL,
display_name TEXT,
account_status TEXT DEFAULT 'pending'
);
`;
try {
await sql`ALTER TABLE users ALTER COLUMN account_status SET DEFAULT 'pending'`;
} catch {
// Ignore if unsupported
}
await sql`
CREATE TABLE IF NOT EXISTS apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
spiffe_id VARCHAR(255) UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
// Ensure app_secret column exists for API validation mapping
try {
await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS app_secret TEXT UNIQUE`;
} catch {
// Soft ignore if column already exists
// Ignore and check next
}
await sql`
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(name, app_id)
);
`;
// Seed standard global roles if table is empty
try {
const existingRoles = await sql`SELECT count(*)::int as count FROM roles`
.then((res) => res[0]?.count || 0);
if (existingRoles === 0) {
await sql`
INSERT INTO roles (name, description, app_id) VALUES
('admin', 'Full administrative access across all management capabilities', NULL),
('editor', 'Read and write access with permissions to modify records', NULL),
('operator', 'Operational execution access for runtime tasks', NULL),
('viewer', 'Read-only access across application telemetry and views', NULL)
ON CONFLICT DO NOTHING
`;
}
} catch {
// Ignore seed errors on race conditions
}
await sql`
CREATE TABLE IF NOT EXISTS grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, app_id)
);
`;
await sql`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'user',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
max_uses INT DEFAULT 1,
uses_count INT DEFAULT 0,
auto_activate BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
used_by UUID REFERENCES users(id) ON DELETE SET NULL
);
`;
// Migrations for existing invites table
try {
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS max_uses INT DEFAULT 1`;
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS uses_count INT DEFAULT 0`;
await sql`ALTER TABLE invites ADD COLUMN IF NOT EXISTS auto_activate BOOLEAN DEFAULT TRUE`;
} catch {
// Ignore migration column exists
}
await sql`
CREATE TABLE IF NOT EXISTS invite_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS aaguid_allowlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aaguid UUID UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS recovery_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE
);
`;
await sql`
CREATE TABLE IF NOT EXISTS audit_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details JSONB,
ip_address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
await sql`
CREATE TABLE IF NOT EXISTS passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key TEXT NOT NULL,
counter BIGINT NOT NULL
);
`;
await sql`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
`;
// Seed ed-droid app record with spiffe_id
await sql`
INSERT INTO apps (name, spiffe_id)
VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend')
ON CONFLICT (spiffe_id) DO NOTHING
`;
// Seed the Central Auth-Yes Management App for global administration
await sql`
INSERT INTO apps (name, spiffe_id)
VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management')
ON CONFLICT (spiffe_id) DO NOTHING
`;
console.log("[Auth DB] Central identity database schema initialized.");
}

5
server/deno.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "@auth-yes/server",
"version": "0.1.0",
"exports": "./main.ts"
}

1974
server/main.ts Normal file

File diff suppressed because it is too large Load Diff

58
server/ratelimit.ts Normal file
View File

@ -0,0 +1,58 @@
import { valkey } from "./valkey.ts";
/**
* Implements a sliding window rate limiter backed by Valkey using Sorted Sets.
*
* @param key - The unique identifier for the limit (e.g. "rate:public:ip:192.168.1.1")
* @param limit - Maximum requests allowed in the window.
* @param windowMs - Size of the window in milliseconds.
* @returns boolean - true if allowed, false if limit exceeded.
*/
export async function checkRateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<boolean> {
const now = Date.now();
const windowStart = now - windowMs;
// Use a multi block to ensure atomicity
const multi = valkey.multi();
// 1. Remove all elements outside the current window
multi.zremrangebyscore(key, 0, windowStart);
// 2. Add the current request timestamp
// Using the timestamp itself as the member and score.
// To avoid collisions if multiple requests happen in the exact same millisecond,
// we could append a random string, but for simple sliding window,
// just the timestamp with a random suffix is safer.
const member = `${now}-${crypto.randomUUID()}`;
multi.zadd(key, now, member);
// 3. Count elements in the current window
multi.zcount(key, "-inf", "+inf");
// 4. Update the key's TTL to automatically clean up
multi.pexpire(key, windowMs);
try {
const results = await multi.exec();
if (!results) {
return false; // Fail closed
}
// The third command in multi is zcount
const countResult = results[2];
if (countResult[0]) {
// If there was an error executing zcount
throw countResult[0];
}
const currentCount = countResult[1] as number;
return currentCount <= limit;
} catch (error) {
console.error("[RateLimit] Error executing multi block:", error);
return false; // Fail closed if Valkey throws an error
}
}

180
server/spire_ffi.ts Normal file
View File

@ -0,0 +1,180 @@
import { X509Certificate } from "npm:@peculiar/x509";
import { AsnParser } from "npm:@peculiar/asn1-schema";
import { SubjectAlternativeName } from "npm:@peculiar/asn1-x509";
// Deno binding for the spire_ffi Rust crate
if (Deno.build.arch !== "x86_64" && Deno.build.arch !== "aarch64") {
throw new Error("Unsupported architecture");
}
const libPath = (() => {
if (Deno.build.os === "windows") return "./spire_ffi.dll";
if (Deno.build.os === "darwin") return "./libspire_ffi.dylib";
return "./libspire_ffi.so";
})();
let dylib: Deno.DynamicLibrary<any> | null = null;
try {
dylib = Deno.dlopen(libPath, {
fetch_svid: {
parameters: ["pointer"],
result: "pointer",
nonblocking: true,
},
free_svid: {
parameters: ["pointer"],
result: "void",
},
});
} catch (_e) {
console.warn(
`Failed to load ${libPath}. Workload API fetching will be mocked/disabled if used.`,
);
}
export interface SvidResponse {
spiffe_id: string;
x509_svid: Uint8Array;
x509_svid_key: Uint8Array;
bundle: Uint8Array;
}
export async function fetchSpiffeIdentity(
socketPath: string = "/var/run/spire/agent.sock",
): Promise<SvidResponse> {
if (!dylib) {
console.warn(
`[SPIRE FFI] Dynamic library (${libPath}) is not loaded. Mocking SVID response for local development.`,
);
return {
spiffe_id: "spiffe://local.dev/mock",
x509_svid: new Uint8Array(),
x509_svid_key: new Uint8Array(),
bundle: new Uint8Array(),
};
}
const encoder = new TextEncoder();
const encodedPath = encoder.encode(socketPath + "\0");
const pathPtr = Deno.UnsafePointer.of(encodedPath);
const fetch_svid = dylib.symbols
.fetch_svid as unknown as ((
ptr: Deno.PointerValue,
) => Promise<Deno.PointerValue>);
const free_svid = dylib.symbols
.free_svid as unknown as ((ptr: Deno.PointerValue) => void);
const resPtr = await fetch_svid(pathPtr);
if (resPtr === null) {
throw new Error("fetch_svid returned a null pointer");
}
const resView = new Deno.UnsafePointerView(resPtr);
let errorMsg: string | null = null;
let spiffe_id: string | null = null;
let offset = 0;
const ptrSize = 8; // 64-bit pointers
const spiffe_id_ptr = resView.getPointer(offset);
offset += ptrSize;
const x509_svid_ptr = resView.getPointer(offset);
offset += ptrSize;
const x509_svid_len = Number(resView.getBigUint64(offset));
offset += ptrSize;
const x509_svid_key_ptr = resView.getPointer(offset);
offset += ptrSize;
const x509_svid_key_len = Number(resView.getBigUint64(offset));
offset += ptrSize;
const bundle_ptr = resView.getPointer(offset);
offset += ptrSize;
const bundle_len = Number(resView.getBigUint64(offset));
offset += ptrSize;
const error_ptr = resView.getPointer(offset);
if (error_ptr !== null) {
errorMsg = new Deno.UnsafePointerView(error_ptr).getCString();
}
if (errorMsg !== null) {
free_svid(resPtr);
throw new Error(errorMsg);
}
if (spiffe_id_ptr !== null) {
spiffe_id = new Deno.UnsafePointerView(spiffe_id_ptr).getCString();
}
if (!spiffe_id) {
free_svid(resPtr);
throw new Error("spiffe_id is null");
}
const x509_svid = x509_svid_ptr !== null && x509_svid_len > 0
? new Uint8Array(
new Deno.UnsafePointerView(x509_svid_ptr).getArrayBuffer(x509_svid_len),
)
: new Uint8Array();
const x509_svid_key = x509_svid_key_ptr !== null && x509_svid_key_len > 0
? new Uint8Array(
new Deno.UnsafePointerView(x509_svid_key_ptr).getArrayBuffer(
x509_svid_key_len,
),
)
: new Uint8Array();
const bundle = bundle_ptr !== null && bundle_len > 0
? new Uint8Array(
new Deno.UnsafePointerView(bundle_ptr).getArrayBuffer(bundle_len),
)
: new Uint8Array();
// Create copies of the typed arrays before freeing the memory
const svidData = {
spiffe_id,
x509_svid: new Uint8Array(x509_svid),
x509_svid_key: new Uint8Array(x509_svid_key),
bundle: new Uint8Array(bundle),
};
// Free the memory on the Rust side
free_svid(resPtr);
return svidData;
}
/**
* Extracts the SPIFFE ID from an incoming client TLS connection.
*/
export function extractSpiffeIdFromCert(certBundle: string): string | null {
try {
const cert = new X509Certificate(certBundle);
const sanExtension = cert.extensions.find((ext) =>
ext.type === "2.5.29.17"
); // Subject Alternative Name
if (!sanExtension) {
return null;
}
const san = AsnParser.parse(sanExtension.value, SubjectAlternativeName);
for (const name of san) {
if (
name.uniformResourceIdentifier &&
name.uniformResourceIdentifier.startsWith("spiffe://")
) {
return name.uniformResourceIdentifier;
}
}
} catch (e) {
console.error("Failed to parse certificate:", e);
return null;
}
return null;
}

25
server/valkey.ts Normal file
View File

@ -0,0 +1,25 @@
import { Redis } from "npm:ioredis";
const VALKEY_URL = Deno.env.get("VALKEY_URL") || "redis://auth-valkey:6379";
export const valkey = new Redis(VALKEY_URL, {
enableOfflineQueue: false,
});
export async function pingValkey(): Promise<void> {
try {
const result = await valkey.ping();
if (result !== "PONG") {
throw new Error(`Unexpected ping response: ${result}`);
}
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Fatal: Failed to connect to Valkey session cache. Halting boot. ${error.message}`,
);
}
throw new Error(
`Fatal: Failed to connect to Valkey session cache. Halting boot.`,
);
}
}

1194
spire_ffi/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
spire_ffi/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "spire_ffi"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
tonic = "0.11"
prost = "0.12"
tokio = { version = "1.37", features = ["full"] }
tokio-stream = { version = "0.1", features = ["net"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
hyper = "1.3"
hyper-util = "0.1"
tower = "0.4"
tower-service = "0.3"
prost-types = "0.12.6"
[build-dependencies]
tonic-build = "0.11"

9
spire_ffi/build.rs Normal file
View File

@ -0,0 +1,9 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(false)
.compile(
&["proto/workload.proto"],
&["proto"],
)?;
Ok(())
}

View File

@ -0,0 +1,221 @@
syntax = "proto3";
import "google/protobuf/struct.proto";
service SpiffeWorkloadAPI {
/////////////////////////////////////////////////////////////////////////
// X509-SVID Profile
/////////////////////////////////////////////////////////////////////////
// Fetch X.509-SVIDs for all SPIFFE identities the workload is entitled to,
// as well as related information like trust bundles and CRLs. As this
// information changes, subsequent messages will be streamed from the
// server.
rpc FetchX509SVID(X509SVIDRequest) returns (stream X509SVIDResponse);
// Fetch trust bundles and CRLs. Useful for clients that only need to
// validate SVIDs without obtaining an SVID for themself. As this
// information changes, subsequent messages will be streamed from the
// server.
rpc FetchX509Bundles(X509BundlesRequest) returns (stream X509BundlesResponse);
/////////////////////////////////////////////////////////////////////////
// JWT-SVID Profile
/////////////////////////////////////////////////////////////////////////
// Fetch JWT-SVIDs for all SPIFFE identities the workload is entitled to,
// for the requested audience. If an optional SPIFFE ID is requested, only
// the JWT-SVID for that SPIFFE ID is returned.
rpc FetchJWTSVID(JWTSVIDRequest) returns (JWTSVIDResponse);
// Fetches the JWT bundles, formatted as JWKS documents, keyed by the
// SPIFFE ID of the trust domain. As this information changes, subsequent
// messages will be streamed from the server.
rpc FetchJWTBundles(JWTBundlesRequest) returns (stream JWTBundlesResponse);
// Validates a JWT-SVID against the requested audience. Returns the SPIFFE
// ID of the JWT-SVID and JWT claims.
rpc ValidateJWTSVID(ValidateJWTSVIDRequest) returns (ValidateJWTSVIDResponse);
/////////////////////////////////////////////////////////////////////////
// WIT-SVID Profile
/////////////////////////////////////////////////////////////////////////
// Fetch WIT-SVIDs for all SPIFFE identities the workload is entitled to.
// As this information changes, subsequent messages will be streamed from
// the server.
rpc FetchWITSVID(WITSVIDRequest) returns (stream WITSVIDResponse);
// Fetch WIT bundles, formatted as JWKS documents, keyed by the SPIFFE ID
// of the trust domain. As this information changes, subsequent messages
// will be streamed from the server.
rpc FetchWITBundles(WITBundlesRequest) returns (stream WITBundlesResponse);
}
// The X509SVIDRequest message conveys parameters for requesting an X.509-SVID.
// There are currently no request parameters.
message X509SVIDRequest { }
// The X509SVIDResponse message carries X.509-SVIDs and related information,
// including a set of global CRLs and a list of bundles the workload may use
// for federating with foreign trust domains.
message X509SVIDResponse {
// Required. A list of X509SVID messages, each of which includes a single
// X.509-SVID, its private key, and the bundle for the trust domain.
repeated X509SVID svids = 1;
// Optional. ASN.1 DER encoded certificate revocation lists.
repeated bytes crl = 2;
// Optional. CA certificate bundles belonging to foreign trust domains that
// the workload should trust, keyed by the SPIFFE ID of the foreign trust
// domain. Bundles are ASN.1 DER encoded.
map<string, bytes> federated_bundles = 3;
}
// The X509SVID message carries a single SVID and all associated information,
// including the X.509 bundle for the trust domain.
message X509SVID {
// Required. The SPIFFE ID of the SVID in this entry
string spiffe_id = 1;
// Required. ASN.1 DER encoded certificate chain. MAY include
// intermediates, the leaf certificate (or SVID itself) MUST come first.
bytes x509_svid = 2;
// Required. ASN.1 DER encoded PKCS#8 private key. MUST be unencrypted.
bytes x509_svid_key = 3;
// Required. ASN.1 DER encoded X.509 bundle for the trust domain.
bytes bundle = 4;
// Optional. An operator-specified string used to provide guidance on how this
// identity should be used by a workload when more than one SVID is returned.
// For example, `internal` and `external` to indicate an SVID for internal or
// external use, respectively.
string hint = 5;
}
// The X509BundlesRequest message conveys parameters for requesting X.509
// bundles. There are currently no such parameters.
message X509BundlesRequest {
}
// The X509BundlesResponse message carries a set of global CRLs and a map of
// trust bundles the workload should trust.
message X509BundlesResponse {
// Optional. ASN.1 DER encoded certificate revocation lists.
repeated bytes crl = 1;
// Required. CA certificate bundles belonging to trust domains that the
// workload should trust, keyed by the SPIFFE ID of the trust domain.
// Bundles are ASN.1 DER encoded.
map<string, bytes> bundles = 2;
}
message JWTSVIDRequest {
// Required. The audience(s) the workload intends to authenticate against.
repeated string audience = 1;
// Optional. The requested SPIFFE ID for the JWT-SVID. If unset, all
// JWT-SVIDs to which the workload is entitled are requested.
string spiffe_id = 2;
}
// The JWTSVIDResponse message conveys JWT-SVIDs.
message JWTSVIDResponse {
// Required. The list of returned JWT-SVIDs.
repeated JWTSVID svids = 1;
}
// The JWTSVID message carries the JWT-SVID token and associated metadata.
message JWTSVID {
// Required. The SPIFFE ID of the JWT-SVID.
string spiffe_id = 1;
// Required. Encoded JWT using JWS Compact Serialization.
string svid = 2;
// Optional. An operator-specified string used to provide guidance on how this
// identity should be used by a workload when more than one SVID is returned.
// For example, `internal` and `external` to indicate an SVID for internal or
// external use, respectively.
string hint = 3;
}
// The JWTBundlesRequest message conveys parameters for requesting JWT bundles.
// There are currently no such parameters.
message JWTBundlesRequest { }
// The JWTBundlesReponse conveys JWT bundles.
message JWTBundlesResponse {
// Required. JWK encoded JWT bundles, keyed by the SPIFFE ID of the trust
// domain.
map<string, bytes> bundles = 1;
}
// The ValidateJWTSVIDRequest message conveys request parameters for
// JWT-SVID validation.
message ValidateJWTSVIDRequest {
// Required. The audience of the validating party. The JWT-SVID must
// contain an audience claim which contains this value in order to
// succesfully validate.
string audience = 1;
// Required. The JWT-SVID to validate, encoded using JWS Compact
// Serialization.
string svid = 2;
}
// The ValidateJWTSVIDReponse message conveys the JWT-SVID validation results.
message ValidateJWTSVIDResponse {
// Required. The SPIFFE ID of the validated JWT-SVID.
string spiffe_id = 1;
// Optional. Arbitrary claims contained within the payload of the validated
// JWT-SVID.
google.protobuf.Struct claims = 2;
}
// WITSVIDRequest conveys parameters for requesting WIT-SVIDs.
message WITSVIDRequest {
// Optional. The requested SPIFFE ID for the WIT-SVID. If unset, all
// WIT-SVIDs to which the workload is entitled are requested.
string spiffe_id = 1;
}
// WITSVIDResponse conveys WIT-SVIDs.
message WITSVIDResponse {
// Required. The list of returned WIT-SVIDs.
repeated WITSVID svids = 1;
}
// WITSVID carries a single WIT-SVID and associated metadata.
message WITSVID {
// Required. The SPIFFE ID of the WIT-SVID.
string spiffe_id = 1;
// Required. Encoded WIT-SVID using JWS Compact Serialization.
string wit_svid = 2;
// Required. JWK-encoded private key bound to this WIT-SVID.
string wit_svid_key = 3;
// Optional. An operator-specified string used to provide guidance on how
// this identity should be used by a workload when more than one SVID is
// returned.
string hint = 4;
}
// WITBundlesRequest conveys parameters for requesting WIT bundles.
// There are currently no such parameters.
message WITBundlesRequest { }
// WITBundlesResponse conveys WIT bundles.
message WITBundlesResponse {
// Required. JWK encoded WIT bundles, keyed by the SPIFFE ID of the trust
// domain.
map<string, string> bundles = 1;
}
option go_package = "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload;workload";

167
spire_ffi/src/lib.rs Normal file
View File

@ -0,0 +1,167 @@
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;
use tokio::net::UnixStream;
use tonic::transport::{Endpoint, Uri};
use tower::service_fn;
// Generated by tonic-build
pub mod workload {
tonic::include_proto!("_");
}
use workload::spiffe_workload_api_client::SpiffeWorkloadApiClient;
use workload::X509svidRequest;
#[repr(C)]
pub struct SvidResponseC {
spiffe_id: *mut c_char,
x509_svid: *mut u8,
x509_svid_len: usize,
x509_svid_key: *mut u8,
x509_svid_key_len: usize,
bundle: *mut u8,
bundle_len: usize,
error: *mut c_char,
}
impl SvidResponseC {
fn with_error(err_msg: &str) -> Self {
let err_c = match CString::new(err_msg) {
Ok(c) => c.into_raw(),
Err(_) => std::ptr::null_mut(),
};
SvidResponseC {
spiffe_id: std::ptr::null_mut(),
x509_svid: std::ptr::null_mut(),
x509_svid_len: 0,
x509_svid_key: std::ptr::null_mut(),
x509_svid_key_len: 0,
bundle: std::ptr::null_mut(),
bundle_len: 0,
error: err_c,
}
}
}
async fn fetch_svid_async(socket_path: &str) -> Result<SvidResponseC, Box<dyn std::error::Error>> {
let path = Path::new(socket_path).to_path_buf();
let channel = Endpoint::try_from("http://[::]:50051")?
.connect_with_connector(service_fn(move |_: Uri| {
let path_clone = path.clone();
async move { UnixStream::connect(path_clone).await }
}))
.await?;
let mut client = SpiffeWorkloadApiClient::new(channel);
let request = tonic::Request::new(X509svidRequest {});
let mut stream = client.fetch_x509svid(request).await?.into_inner();
if let Some(response) = stream.message().await? {
if let Some(svid) = response.svids.first() {
let spiffe_id_c = CString::new(svid.spiffe_id.clone())?.into_raw();
let mut svid_box = svid.x509_svid.clone().into_boxed_slice();
let x509_svid = svid_box.as_mut_ptr();
let x509_svid_len = svid_box.len();
std::mem::forget(svid_box);
let mut key_box = svid.x509_svid_key.clone().into_boxed_slice();
let x509_svid_key = key_box.as_mut_ptr();
let x509_svid_key_len = key_box.len();
std::mem::forget(key_box);
let mut bundle_box = svid.bundle.clone().into_boxed_slice();
let bundle = bundle_box.as_mut_ptr();
let bundle_len = bundle_box.len();
std::mem::forget(bundle_box);
return Ok(SvidResponseC {
spiffe_id: spiffe_id_c,
x509_svid,
x509_svid_len,
x509_svid_key,
x509_svid_key_len,
bundle,
bundle_len,
error: std::ptr::null_mut(),
});
}
}
Err("No SVID received from Workload API".into())
}
#[no_mangle]
pub extern "C" fn fetch_svid(socket_path_ptr: *const c_char) -> *mut SvidResponseC {
if socket_path_ptr.is_null() {
let resp = Box::new(SvidResponseC::with_error("socket_path_ptr is null"));
return Box::into_raw(resp);
}
let socket_path = unsafe {
match CStr::from_ptr(socket_path_ptr).to_str() {
Ok(s) => s.to_string(),
Err(e) => {
let resp = Box::new(SvidResponseC::with_error(&format!("Invalid UTF-8 in socket path: {}", e)));
return Box::into_raw(resp);
}
}
};
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
let resp = Box::new(SvidResponseC::with_error(&format!("Failed to build tokio runtime: {}", e)));
return Box::into_raw(resp);
}
};
let result = match rt.block_on(fetch_svid_async(&socket_path)) {
Ok(data) => data,
Err(e) => SvidResponseC::with_error(&e.to_string()),
};
Box::into_raw(Box::new(result))
}
#[no_mangle]
pub extern "C" fn free_svid(ptr: *mut SvidResponseC) {
if ptr.is_null() {
return;
}
unsafe {
let mut resp = Box::from_raw(ptr);
if !resp.spiffe_id.is_null() {
let _ = CString::from_raw(resp.spiffe_id);
resp.spiffe_id = std::ptr::null_mut();
}
if !resp.error.is_null() {
let _ = CString::from_raw(resp.error);
resp.error = std::ptr::null_mut();
}
if !resp.x509_svid.is_null() && resp.x509_svid_len > 0 {
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(resp.x509_svid, resp.x509_svid_len));
resp.x509_svid = std::ptr::null_mut();
resp.x509_svid_len = 0;
}
if !resp.x509_svid_key.is_null() && resp.x509_svid_key_len > 0 {
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(resp.x509_svid_key, resp.x509_svid_key_len));
resp.x509_svid_key = std::ptr::null_mut();
resp.x509_svid_key_len = 0;
}
if !resp.bundle.is_null() && resp.bundle_len > 0 {
let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(resp.bundle, resp.bundle_len));
resp.bundle = std::ptr::null_mut();
resp.bundle_len = 0;
}
}
}

1
test.ts Normal file
View File

@ -0,0 +1 @@
Deno.test("Dummy test to pass deno test", () => {});

View File

@ -0,0 +1,167 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AAGUIDPage = ({ allowlist }: { allowlist: any[] }) => {
return (
<AdminLayout title="AAGUID Allow-List" currentPath="/admin/aaguid">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div class="card">
<h2>AAGUID Allow-List Management</h2>
<p>
Manage the enterprise allow-list of approved hardware Authenticator
Attestation GUIDs (AAGUIDs). If this list is populated, only passkeys
matching these AAGUIDs will be permitted to register. If empty, all
certified hardware passkeys are allowed (software passkeys are always
rejected).
</p>
<form
id="add-aaguid-form"
style="margin-bottom: 2rem; display: flex; gap: 1rem; align-items: flex-end;"
>
<div style="flex: 1;">
<label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
AAGUID (UUID format)
</label>
<input
type="text"
name="aaguid"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
/>
</div>
<div style="flex: 2;">
<label style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
Description (e.g. YubiKey 5 NFC)
</label>
<input
type="text"
name="description"
placeholder="Hardware Key Model"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
/>
</div>
<div>
<button
type="submit"
class="btn-action btn-success"
style="padding: 0.6rem 1rem;"
>
Add to Allow-List
</button>
</div>
</form>
<div class="table-container">
<table>
<thead>
<tr>
<th>AAGUID</th>
<th>Description</th>
<th>Added On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{allowlist.length === 0
? (
<tr>
<td colspan={4} style="text-align: center; color: #6c757d;">
The allow-list is empty. All hardware passkeys are
currently accepted.
</td>
</tr>
)
: (
allowlist.map((item: any) => (
<tr key={item.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;">
{item.aaguid}
</code>
</td>
<td>{item.description || "-"}</td>
<td>{new Date(item.created_at).toLocaleString()}</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`removeAaguid('${item.id}')`}
>
Remove
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
document.getElementById('add-aaguid-form').addEventListener('submit', async (e) => {
e.preventDefault();
const aaguid = e.target.aaguid.value;
const description = e.target.description.value;
try {
const res = await fetch('/api/admin/aaguid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aaguid, description })
});
if (res.ok) {
showNotice('AAGUID added to allow-list', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to add AAGUID', true);
}
} catch (err) {
showNotice('Network error', true);
}
});
async function removeAaguid(id) {
if (!confirm('Are you sure you want to remove this AAGUID from the allow-list?')) {
return;
}
try {
const res = await fetch('/api/admin/aaguid/' + id, {
method: 'DELETE'
});
if (res.ok) {
showNotice('AAGUID removed', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to remove AAGUID', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
>
</script>
</AdminLayout>
);
};

View File

@ -0,0 +1,234 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AdminAppsPage = ({
apps,
}: {
apps: any[];
}) => {
return (
<AdminLayout title="Application Registry" currentPath="/admin/apps">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2 style="margin: 0; border: none; padding: 0;">
Connected Applications
</h2>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
onclick="toggleRegisterForm()"
>
+ Register Application
</button>
</div>
<div
id="register-app-card"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
>
<h3>Register New Subsidiary Application</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Register an internal microservice or subsidiary application. The
system will authenticate incoming ConnectRPC requests against the
application's SPIFFE ID.
</p>
<form id="registerAppForm" onsubmit="handleRegisterApp(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Application Name *
</label>
<input
type="text"
id="appName"
name="name"
placeholder="e.g. Elite Dangerous Streaming Hub"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
SPIFFE ID (Workload Identity) *
</label>
<input
type="text"
id="appSpiffeId"
name="spiffeId"
placeholder="e.g. spiffe://system.local/ed-droid-backend"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Description (Optional)
</label>
<input
type="text"
id="appDescription"
name="description"
placeholder="e.g. Headless data streaming hub and UI module system"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
Save Application
</button>
<button
type="button"
class="btn-action"
onclick="toggleRegisterForm()"
>
Cancel
</button>
</div>
</form>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Active Users / Grants</th>
<th>Description</th>
<th>Registered Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apps.length === 0
? (
<tr>
<td
colspan={6}
style="text-align: center; color: #6c757d; padding: 2rem;"
>
No connected applications registered yet.
</td>
</tr>
)
: (
apps.map((app) => (
<tr key={app.id}>
<td>
<strong>{app.name}</strong>
</td>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
{app.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
<td style="color: #6c757d; font-size: 0.85rem;">
{app.description || "-"}
</td>
<td style="font-size: 0.85rem;">
{new Date(app.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`deleteApp('${app.id}', '${app.name}')`}
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
function toggleRegisterForm() {
const el = document.getElementById('register-app-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
async function handleRegisterApp(e) {
e.preventDefault();
const name = document.getElementById('appName').value.trim();
const spiffeId = document.getElementById('appSpiffeId').value.trim();
const description = document.getElementById('appDescription').value.trim();
if (!name || !spiffeId) {
showNotice('Name and SPIFFE ID are required', true);
return;
}
try {
const res = await fetch('/api/admin/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spiffeId, description }),
});
const data = await res.json();
if (res.ok) {
showNotice('Application registered successfully!', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice(data.error || 'Failed to register application', true);
}
} catch (err) {
showNotice('Network error registering application', true);
}
}
async function deleteApp(appId, appName) {
if (!confirm('Are you sure you want to delete "' + appName + '"? All active user permissions for this app will be revoked.')) {
return;
}
try {
const res = await fetch('/api/admin/apps/' + appId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Application deleted', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete application', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
/>
</AdminLayout>
);
};

View File

@ -0,0 +1,612 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AdminInvitesPage = ({
invites,
apps,
allRoles = [],
}: {
invites: any[];
apps: any[];
allRoles?: any[];
}) => {
return (
<AdminLayout
title="Invite & Onboarding Tokens"
currentPath="/admin/invites"
>
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
Invite & Onboarding Tokens
</h2>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
Issue single-use, team limited-use, or campaign-wide registration
tokens.
</p>
</div>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
onclick="toggleCreateInviteForm()"
>
+ Generate Onboarding Token
</button>
</div>
<div
id="create-invite-card"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
>
<h3>Generate User Onboarding Token</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Configure time bounds, usage limits, role assignments, and initial
account activation status.
</p>
<form id="createInviteForm" onsubmit="handleCreateInvite(event)">
{/* Row 1: Type & Target App */}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Token Provisioning Type *
</label>
<select
id="inviteType"
onchange="handleInviteTypeChange()"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
<option value="site_scoped">
Type 2: Site-Scoped Token (Pre-Authorized for App)
</option>
<option value="global_admin">
Type 1: Global Admin Token (Full System Access)
</option>
<option value="open_pending">
Type 3: General Open Token (Unassigned Access)
</option>
</select>
</div>
<div id="appSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Target Application *
</label>
<select
id="inviteAppId"
onchange="updateInviteRoleOptions()"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
{apps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
</div>
{/* Row 2: Usage Limits & Assigned Role */}
<div style="display: grid; grid-template-columns: 1.5fr 1fr 1.5fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Usage Policy (Capacity) *
</label>
<select
id="inviteUsageType"
onchange="handleUsageTypeChange()"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
<option value="single">
Single-Use (1 Person - Max Security)
</option>
<option value="limited">
Limited Multi-Use (Cap at N People)
</option>
<option value="unlimited">
Unlimited Time-Bound (Campaign / Beta)
</option>
</select>
</div>
<div id="maxUsesContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Max Registrations *
</label>
<input
type="number"
id="inviteMaxUses"
value="5"
min="2"
max="1000"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div id="roleSelectContainer">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Assigned Role *
</label>
<select
id="inviteRole"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
{/* Dynamically populated */}
</select>
</div>
</div>
{/* Row 3: Expiration, Custom Code, Activation Toggle */}
<div style="display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 1rem; margin-bottom: 1.2rem; align-items: flex-end;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Expires In (Days)
</label>
<input
type="number"
id="inviteExpiresInDays"
value="7"
min="1"
max="30"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Custom Code (Optional)
</label>
<input
type="text"
id="inviteCustomCode"
placeholder="Leave blank to auto-generate"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div style="padding-bottom: 0.4rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer;">
<input
type="checkbox"
id="inviteAutoActivate"
checked
style="width: 16px; height: 16px; cursor: pointer;"
/>
Auto-Activate Account
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
Create Invite Token
</button>
<button
type="button"
class="btn-action"
onclick="toggleCreateInviteForm()"
>
Cancel
</button>
</div>
</form>
<div
id="generated-token-banner"
style="display: none; margin-top: 1rem; padding: 1rem; background: #e7f5ea; border: 1px solid #28a745; border-radius: 4px;"
>
<strong style="color: #155724;">Token Created Successfully!</strong>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
<code
id="generatedTokenUrl"
style="padding: 0.4rem 0.6rem; background: white; border: 1px solid #ced4da; border-radius: 4px; font-size: 0.9rem; flex: 1; word-break: break-all;"
>
</code>
<button
type="button"
class="btn-action btn-success"
onclick="copyGeneratedTokenUrl()"
>
Copy Link
</button>
</div>
</div>
</div>
{/* Invites Ledger Table */}
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Invite Code</th>
<th>Scope / App</th>
<th>Role</th>
<th>Usage & Capacity</th>
<th>Status</th>
<th>Activation</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invites.length === 0
? (
<tr>
<td
colspan={8}
style="text-align: center; color: #6c757d; padding: 2rem;"
>
No active or historical invite tokens found.
</td>
</tr>
)
: (
invites.map((inv) => {
const usesCount = inv.uses_count || 0;
const maxUses = inv.max_uses; // null = unlimited, number = limit
const isUnlimited = maxUses === null;
const isExhausted = !isUnlimited && usesCount >= maxUses;
const isExpired = new Date(inv.expires_at) < new Date();
const isActive = !isExhausted && !isExpired;
return (
<tr key={inv.id}>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-weight: bold; color: #212529;">
{inv.code}
</code>
</td>
<td>
{inv.app_name
? <strong>{inv.app_name}</strong>
: inv.role === "admin"
? <span class="badge badge-info">Global Admin</span>
: (
<span class="badge badge-secondary">
General (Unassigned)
</span>
)}
</td>
<td>
<span class="badge badge-info">{inv.role}</span>
</td>
<td>
<div style="min-width: 110px;">
{isUnlimited
? (
<span style="font-size: 0.85rem; font-weight: 500; color: #0d6efd;">
{usesCount} claimed (Unlimited)
</span>
)
: (
<div>
<span style="font-size: 0.85rem; font-weight: 600;">
{usesCount} / {maxUses} used
</span>
<div style="background: #e9ecef; border-radius: 3px; height: 6px; width: 100%; margin-top: 4px; overflow: hidden;">
<div
style={`background: ${
isExhausted ? "#6c757d" : "#28a745"
}; height: 100%; width: ${
Math.min(
100,
(usesCount / maxUses) * 100,
)
}%;`}
/>
</div>
</div>
)}
</div>
</td>
<td>
{isExhausted && (
<span class="badge badge-secondary">Exhausted</span>
)}
{isExpired && !isExhausted && (
<span class="badge badge-suspended">Expired</span>
)}
{isActive && (
<span class="badge badge-active">Active</span>
)}
</td>
<td>
{inv.auto_activate !== false
? (
<span style="font-size: 0.8rem; color: #198754; font-weight: 500;">
Auto-Active
</span>
)
: (
<span style="font-size: 0.8rem; color: #fd7e14; font-weight: 500;">
Requires Approval
</span>
)}
</td>
<td style="font-size: 0.85rem;">
{new Date(inv.expires_at).toLocaleDateString()}
</td>
<td>
<div style="display: flex; gap: 0.3rem; flex-wrap: wrap;">
{isActive && (
<button
type="button"
class="btn-action btn-success"
onclick={`copyInviteLink('${inv.code}')`}
>
Copy Link
</button>
)}
{usesCount > 0 && (
<button
type="button"
class="btn-action"
style="background: #e2e3e5; color: #383d41;"
onclick={`showRedemptionsModal('${inv.id}', '${inv.code}')`}
>
Claimed ({usesCount})
</button>
)}
{isActive && (
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeInvite('${inv.id}', '${inv.code}')`}
>
Revoke
</button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Redemptions Modal */}
<div
id="redemptions-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 9999; justify-content: center; align-items: center;"
>
<div style="background: white; border-radius: 8px; width: 90%; max-width: 550px; padding: 1.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 1.1rem;">
Users Claimed:{" "}
<code id="modal-invite-code" style="color: #0d6efd;"></code>
</h3>
<button
type="button"
onclick="closeRedemptionsModal()"
style="background: none; border: none; font-size: 1.2rem; cursor: pointer; color: #6c757d;"
>
&times;
</button>
</div>
<div
id="modal-redemptions-content"
style="max-height: 350px; overflow-y: auto;"
>
<p style="color: #6c757d; font-size: 0.9rem;">
Loading claimed users...
</p>
</div>
<div style="text-align: right; margin-top: 1rem;">
<button
type="button"
class="btn-action"
onclick="closeRedemptionsModal()"
>
Close
</button>
</div>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
function updateInviteRoleOptions() {
const appSelect = document.getElementById('inviteAppId');
const roleSelect = document.getElementById('inviteRole');
if (!appSelect || !roleSelect) return;
const appId = appSelect.value;
roleSelect.innerHTML = '';
const available = ROLES_CATALOG.filter(r => !r.app_id || r.app_id === appId);
if (available.length === 0) {
const opt = document.createElement('option');
opt.value = 'user';
opt.textContent = 'user';
roleSelect.appendChild(opt);
return;
}
available.forEach(r => {
const opt = document.createElement('option');
opt.value = r.name;
opt.textContent = r.name + (r.app_id ? ' (App Custom)' : ' (Global)');
roleSelect.appendChild(opt);
});
}
if (document.getElementById('inviteAppId')) {
updateInviteRoleOptions();
}
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
function toggleCreateInviteForm() {
const el = document.getElementById('create-invite-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function handleInviteTypeChange() {
const type = document.getElementById('inviteType').value;
const appContainer = document.getElementById('appSelectContainer');
const roleContainer = document.getElementById('roleSelectContainer');
if (type === 'global_admin' || type === 'open_pending') {
appContainer.style.display = 'none';
roleContainer.style.display = 'none';
} else {
appContainer.style.display = 'block';
roleContainer.style.display = 'block';
updateInviteRoleOptions();
}
}
function handleUsageTypeChange() {
const usage = document.getElementById('inviteUsageType').value;
const maxUsesContainer = document.getElementById('maxUsesContainer');
maxUsesContainer.style.display = usage === 'limited' ? 'block' : 'none';
}
async function handleCreateInvite(e) {
e.preventDefault();
const type = document.getElementById('inviteType').value;
let appId = null;
let role = 'user';
if (type === 'global_admin') {
role = 'admin';
} else if (type === 'open_pending') {
role = 'user';
} else {
appId = document.getElementById('inviteAppId').value;
role = document.getElementById('inviteRole').value;
}
const usageLimitType = document.getElementById('inviteUsageType').value;
const maxUses = usageLimitType === 'limited' ? parseInt(document.getElementById('inviteMaxUses').value) || 5 : null;
const autoActivate = document.getElementById('inviteAutoActivate').checked;
const expiresInDays = parseInt(document.getElementById('inviteExpiresInDays').value) || 7;
const customCode = document.getElementById('inviteCustomCode').value.trim();
try {
const res = await fetch('/api/admin/invites/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appId,
role,
usageLimitType,
maxUses,
autoActivate,
expiresInDays,
customCode: customCode || undefined,
}),
});
const data = await res.json();
if (res.ok) {
const regUrl = window.location.origin + '/register?code=' + data.inviteCode;
document.getElementById('generatedTokenUrl').textContent = regUrl;
document.getElementById('generated-token-banner').style.display = 'block';
showNotice('Invite token created successfully!', false);
setTimeout(() => { window.location.reload(); }, 2500);
} else {
showNotice(data.error || 'Failed to create invite token', true);
}
} catch (err) {
showNotice('Network error creating invite', true);
}
}
function copyGeneratedTokenUrl() {
const text = document.getElementById('generatedTokenUrl').textContent;
navigator.clipboard.writeText(text);
showNotice('Registration URL copied to clipboard: ' + text, false);
}
function copyInviteLink(code) {
const url = window.location.origin + '/register?code=' + code;
navigator.clipboard.writeText(url);
showNotice('Registration link copied: ' + url, false);
}
async function revokeInvite(inviteId, code) {
if (!confirm('Revoke invite code "' + code + '"?')) return;
try {
const res = await fetch('/api/admin/invites/' + inviteId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Invite token revoked', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke invite', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function showRedemptionsModal(inviteId, code) {
const modal = document.getElementById('redemptions-modal');
const codeEl = document.getElementById('modal-invite-code');
const contentEl = document.getElementById('modal-redemptions-content');
codeEl.textContent = code;
contentEl.innerHTML = '<p style="color: #6c757d;">Loading...</p>';
modal.style.display = 'flex';
try {
const res = await fetch('/api/admin/invites/' + inviteId + '/redemptions');
const data = await res.json();
if (res.ok && data.redemptions && data.redemptions.length > 0) {
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<thead><tr style="text-align: left; border-bottom: 2px solid #dee2e6;">';
html += '<th style="padding: 0.4rem;">Username</th>';
html += '<th style="padding: 0.4rem;">Status</th>';
html += '<th style="padding: 0.4rem;">Redeemed At</th>';
html += '</tr></thead><tbody>';
data.redemptions.forEach(r => {
html += '<tr style="border-bottom: 1px solid #dee2e6;">';
html += '<td style="padding: 0.4rem;"><strong>' + r.username + '</strong></td>';
html += '<td style="padding: 0.4rem;"><span class="badge badge-' + r.account_status + '">' + r.account_status + '</span></td>';
html += '<td style="padding: 0.4rem; color: #6c757d;">' + new Date(r.redeemed_at).toLocaleString() + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
contentEl.innerHTML = html;
} else {
contentEl.innerHTML = '<p style="color: #6c757d; text-align: center; padding: 1rem;">No users have redeemed this token yet.</p>';
}
} catch (err) {
contentEl.innerHTML = '<p style="color: #dc3545;">Failed to load redemption details.</p>';
}
}
function closeRedemptionsModal() {
document.getElementById('redemptions-modal').style.display = 'none';
}
`,
}}
/>
</AdminLayout>
);
};

View File

@ -0,0 +1,188 @@
export const AdminLayout = ({
children,
title,
currentPath,
}: {
children: any;
title: string;
currentPath: string;
}) => {
const navItems = [
{ label: "← User Dashboard", href: "/dashboard" },
{ label: "Users", href: "/admin/users" },
{ label: "Applications", href: "/admin/apps" },
{ label: "Roles", href: "/admin/roles" },
{ label: "Invite Tokens", href: "/admin/invites" },
{ label: "AAGUID Allow-List", href: "/admin/aaguid" },
{ label: "Audit Logs", href: "/admin/audit-logs" },
];
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} - Auth-Yes Admin</title>
<style>
{`
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f4f6f8;
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
background: #343a40;
color: white;
border-bottom: 1px solid #23272b;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.header h1 {
margin: 0;
font-size: 1.25rem;
color: #ffffff;
}
.nav {
display: flex;
gap: 1.5rem;
}
.nav a {
text-decoration: none;
color: #adb5bd;
font-weight: 500;
padding: 0.5rem 0;
}
.nav a:hover {
color: #ffffff;
}
.nav a.active {
color: #ffffff;
border-bottom: 2px solid #ffffff;
}
.main-content {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 1.5rem;
border: 1px solid #e9ecef;
}
h2 {
margin-top: 0;
color: #343a40;
font-size: 1.25rem;
border-bottom: 1px solid #e9ecef;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #dee2e6;
font-size: 0.9rem;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.btn-action {
background: #e9ecef;
color: #495057;
border: 1px solid #ced4da;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
margin-right: 0.5rem;
}
.btn-action:hover {
background: #dee2e6;
}
.btn-success {
background: #28a745;
color: white;
border: 1px solid #28a745;
}
.btn-success:hover { background: #218838; }
.btn-warning {
background: #ffc107;
color: #212529;
border: 1px solid #ffc107;
}
.btn-warning:hover { background: #e0a800; }
.badge {
display: inline-block;
padding: 0.25em 0.4em;
font-size: 75%;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
}
.badge-active { background-color: #28a745; color: white; }
.badge-pending { background-color: #ffc107; color: #212529; }
.badge-suspended { background-color: #dc3545; color: white; }
pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; font-size: 0.8rem; }
`}
</style>
</head>
<body>
<header class="header">
<div style="display: flex; align-items: center; gap: 2rem;">
<h1>Auth-Yes Admin Console</h1>
<nav class="nav">
{navItems.map((item) => {
const isActive = currentPath === item.href;
return (
<a
href={item.href}
class={isActive ? "active" : ""}
>
{item.label}
</a>
);
})}
</nav>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<a
href="/logout"
class="btn-action btn-warning"
style="text-decoration: none; padding: 0.35rem 0.8rem; font-size: 0.85rem; border-radius: 4px;"
>
Logout
</a>
</div>
</header>
<main class="main-content">
{children}
</main>
</body>
</html>
);
};

View File

@ -0,0 +1,341 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AdminRolesPage = ({
roles,
apps,
}: {
roles: any[];
apps: any[];
}) => {
return (
<AdminLayout title="Role & Permission Catalog" currentPath="/admin/roles">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
Role & Permission Catalog
</h2>
<p style="color: #6c757d; font-size: 0.9rem; margin: 0.2rem 0 0 0;">
Manage global and application-specific RBAC roles and permissions.
</p>
</div>
<button
type="button"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; font-size: 0.9rem;"
onclick="toggleCreateRoleForm()"
>
+ Create Custom Role
</button>
</div>
<div
id="create-role-card"
class="card"
style="display: none; border-left: 4px solid #28a745; margin-bottom: 1.5rem;"
>
<h3>Create New Role</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Define a global shared role or an application-scoped custom role.
</p>
<form id="createRoleForm" onsubmit="handleCreateRole(event)">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Scope (Applicability) *
</label>
<select
id="roleScope"
onchange="handleScopeChange()"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
<option value="global">
Global (Shared across ALL applications)
</option>
<option value="app_specific">
Application-Specific (Scoped to single app)
</option>
</select>
</div>
<div id="appSelectContainer" style="display: none;">
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Target Application *
</label>
<select
id="roleAppId"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box; background: white;"
>
{apps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; margin-bottom: 1rem;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Role Identifier *
</label>
<input
type="text"
id="roleName"
placeholder="e.g. navigator, copilot, auditor"
required
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 0.3rem; font-size: 0.85rem;">
Description / Purpose
</label>
<input
type="text"
id="roleDescription"
placeholder="e.g. Flight routing and navigational telemetry access"
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;"
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn-action btn-success">
Save Role
</button>
<button
type="button"
class="btn-action"
onclick="toggleCreateRoleForm()"
>
Cancel
</button>
</div>
</form>
</div>
<div class="card">
{/* Filter Controls */}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label style="font-weight: 600; font-size: 0.85rem;">
Filter Scope:
</label>
<select
id="filterScopeSelect"
onchange="filterRolesTable()"
style="padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.85rem;"
>
<option value="all">All Roles</option>
<option value="global">Global (Shared) Only</option>
{apps.map((app) => (
<option value={app.id}>
{app.name} Only
</option>
))}
</select>
</div>
<span
id="roleCountDisplay"
style="font-size: 0.85rem; color: #6c757d;"
>
Showing {roles.length} roles
</span>
</div>
<div class="table-container">
<table id="rolesTable">
<thead>
<tr>
<th>Role Name</th>
<th>Scope</th>
<th>Description</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{roles.length === 0
? (
<tr>
<td
colspan={5}
style="text-align: center; color: #6c757d; padding: 2rem;"
>
No roles found.
</td>
</tr>
)
: (
roles.map((r) => {
const isGlobal = !r.app_id;
const isCoreAdmin = isGlobal && r.name === "admin";
return (
<tr key={r.id} data-app-id={r.app_id || "global"}>
<td>
<strong style="font-family: monospace; font-size: 0.95rem; color: #212529;">
{r.name}
</strong>
</td>
<td>
{isGlobal
? (
<span class="badge badge-info">
Global (Shared)
</span>
)
: (
<span class="badge badge-pending">
{r.app_name || "App-Specific"}
</span>
)}
</td>
<td style="color: #495057; font-size: 0.85rem;">
{r.description || "-"}
</td>
<td style="font-size: 0.85rem;">
{new Date(r.created_at).toLocaleDateString()}
</td>
<td>
{!isCoreAdmin
? (
<button
type="button"
class="btn-action btn-warning"
onclick={`deleteRole('${r.id}', '${r.name}')`}
>
Delete
</button>
)
: (
<span style="color: #6c757d; font-size: 0.8rem; font-style: italic;">
System Core
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
function toggleCreateRoleForm() {
const el = document.getElementById('create-role-card');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function handleScopeChange() {
const scope = document.getElementById('roleScope').value;
const appContainer = document.getElementById('appSelectContainer');
appContainer.style.display = scope === 'app_specific' ? 'block' : 'none';
}
function filterRolesTable() {
const selected = document.getElementById('filterScopeSelect').value;
const rows = document.querySelectorAll('#rolesTable tbody tr');
let visibleCount = 0;
rows.forEach((row) => {
const rowAppId = row.getAttribute('data-app-id');
if (!rowAppId) return;
if (selected === 'all') {
row.style.display = '';
visibleCount++;
} else if (selected === 'global') {
const isGlobal = rowAppId === 'global';
row.style.display = isGlobal ? '' : 'none';
if (isGlobal) visibleCount++;
} else {
const isMatch = rowAppId === selected;
row.style.display = isMatch ? '' : 'none';
if (isMatch) visibleCount++;
}
});
document.getElementById('roleCountDisplay').textContent = 'Showing ' + visibleCount + ' roles';
}
async function handleCreateRole(e) {
e.preventDefault();
const scope = document.getElementById('roleScope').value;
const name = document.getElementById('roleName').value.trim();
const description = document.getElementById('roleDescription').value.trim();
let appId = null;
if (scope === 'app_specific') {
appId = document.getElementById('roleAppId').value;
}
if (!name) {
showNotice('Role identifier is required', true);
return;
}
try {
const res = await fetch('/api/admin/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, appId }),
});
const data = await res.json();
if (res.ok) {
showNotice('Role "' + name + '" created successfully!', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice(data.error || 'Failed to create role', true);
}
} catch (err) {
showNotice('Network error creating role', true);
}
}
async function deleteRole(roleId, roleName) {
if (!confirm('Are you sure you want to delete role "' + roleName + '"?')) return;
try {
const res = await fetch('/api/admin/roles/' + roleId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Role deleted', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to delete role', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
/>
</AdminLayout>
);
};

View File

@ -0,0 +1,459 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AdminUserDetailsPage = ({
user,
sessions,
passkeys,
grants = [],
allApps = [],
allRoles = [],
}: {
user: any;
sessions: any[];
passkeys: any[];
grants?: any[];
allApps?: any[];
allRoles?: any[];
}) => {
return (
<AdminLayout title={`User: ${user.username}`} currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="margin: 0; border: none; padding: 0;">
User Profile: {user.username}
</h2>
<span style="font-size: 0.85rem; color: #6c757d;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
style="color: #007bff; text-decoration: none; font-weight: 500;"
>
&larr; Back to Users
</a>
</div>
{/* Application RBAC Access Matrix */}
<div class="card" style="border-left: 4px solid #0d6efd;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h3 style="margin: 0;">Application Access & RBAC Grants</h3>
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
Manage this user's explicit permissions across registered
applications (Default-Deny Zero-Trust).
</p>
</div>
</div>
{/* Grant New Application Form */}
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem;">
Assign / Update Application Access
</h4>
<form
id="grantAccessForm"
onsubmit={`handleGrantAccess(event, '${user.id}')`}
style="display: flex; gap: 0.8rem; align-items: flex-end; flex-wrap: wrap;"
>
<div style="flex: 2; min-width: 200px;">
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem;">
Application
</label>
<select
id="grantAppId"
onchange="updateRoleOptions()"
required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
>
{allApps.map((app) => (
<option value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
<div style="flex: 1; min-width: 140px;">
<label style="display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem;">
Assigned Role
</label>
<select
id="grantRole"
required
style="width: 100%; padding: 0.45rem; border: 1px solid #ced4da; border-radius: 4px; background: white;"
>
{/* Dynamically populated */}
</select>
</div>
<button
type="submit"
class="btn-action btn-success"
style="padding: 0.5rem 1rem; height: fit-content;"
>
Save Grant
</button>
</form>
</div>
<div class="table-container" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colspan={5}
style="text-align: center; color: #dc3545; padding: 1.5rem;"
>
No application permissions granted (User is blocked from
all subsidiary apps).
</td>
</tr>
)
: (
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong>{grant.app_name}</strong>
</td>
<td>
<code style="background: #e9ecef; padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; color: #0d6efd;">
{grant.spiffe_id}
</code>
</td>
<td>
<span
class={`badge ${
grant.role === "admin"
? "badge-suspended"
: "badge-info"
}`}
>
{grant.role}
</span>
</td>
<td style="font-size: 0.85rem;">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke Access
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Out-of-band Recovery */}
<div class="card">
<h3>Out-of-Band Account Recovery</h3>
<p style="color: #6c757d; font-size: 0.9rem;">
Generate a one-time recovery link to allow the user to bind a new
hardware passkey if all devices are lost.
</p>
<button
type="button"
class="btn-action btn-success"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px;"
>
<p style="margin-top: 0; font-weight: 500;">
Provide this link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: #d63384;"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: #6c757d;">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">Active Sessions</h3>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
</div>
<div class="table-container" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sessions.length === 0
? (
<tr>
<td colspan={4} style="text-align: center; color: #6c757d;">
No active sessions.
</td>
</tr>
)
: (
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px;">
{session.id.substring(0, 8)}...
</code>
</td>
<td>{new Date(session.created_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`revokeSession('${session.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin-top: 0;">Registered Passkeys</h3>
<div class="table-container" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th>Credential ID</th>
<th>Counter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td colspan={3} style="text-align: center; color: #6c757d;">
No registered passkeys.
</td>
</tr>
)
: (
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: #f8f9fa; padding: 0.2rem 0.4rem; border-radius: 3px; word-break: break-all;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td>{pk.counter}</td>
<td>
<button
type="button"
class="btn-action btn-warning"
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
>
Delete Device
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
const ROLES_CATALOG = ${JSON.stringify(allRoles)};
function updateRoleOptions() {
const appId = document.getElementById('grantAppId').value;
const roleSelect = document.getElementById('grantRole');
roleSelect.innerHTML = '';
const available = ROLES_CATALOG.filter(r => !r.app_id || r.app_id === appId);
if (available.length === 0) {
const opt = document.createElement('option');
opt.value = 'user';
opt.textContent = 'user';
roleSelect.appendChild(opt);
return;
}
available.forEach(r => {
const opt = document.createElement('option');
opt.value = r.name;
opt.textContent = r.name + (r.app_id ? ' (App Custom)' : ' (Global)');
roleSelect.appendChild(opt);
});
}
// Initial populate
if (document.getElementById('grantAppId')) {
updateRoleOptions();
}
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
async function handleGrantAccess(e, userId) {
e.preventDefault();
const appId = document.getElementById('grantAppId').value;
const role = document.getElementById('grantRole').value;
try {
const res = await fetch('/api/admin/users/' + userId + '/grants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId, role }),
});
const data = await res.json();
if (res.ok) {
showNotice('Application access granted successfully!', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice(data.error || 'Failed to update application grant', true);
}
} catch (err) {
showNotice('Network error updating grant', true);
}
}
async function revokeGrant(userId, appId, appName) {
if (!confirm('Revoke access to "' + appName + '" for this user?')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/grants/' + appId, {
method: 'DELETE',
});
if (res.ok) {
showNotice('Access revoked', false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to revoke grant', true);
}
} catch (err) {
showNotice('Network error revoking grant', true);
}
}
async function generateRecoveryLink(userId) {
try {
const res = await fetch('/api/admin/users/' + userId + '/recovery', { method: 'POST' });
const data = await res.json();
if (res.ok) {
const link = window.location.origin + '/recovery?code=' + data.recoveryCode;
document.getElementById('recovery-link-text').textContent = link;
document.getElementById('recovery-link-container').style.display = 'block';
showNotice('Recovery link generated!', false);
} else {
showNotice(data.error || 'Failed to generate link', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function revokeSession(sessionId) {
if (!confirm('Revoke this session?')) return;
try {
const res = await fetch('/api/admin/sessions/' + sessionId, { method: 'DELETE' });
if (res.ok) {
showNotice('Session revoked', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice('Failed to revoke session', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function revokeAllSessions(userId) {
if (!confirm('Revoke ALL sessions for this user? They will be immediately logged out.')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/sessions', { method: 'DELETE' });
if (res.ok) {
showNotice('All sessions revoked', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice('Failed to revoke all sessions', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
async function deletePasskey(userId, passkeyId) {
if (!confirm('Permanently delete this device? The user will no longer be able to log in with it.')) return;
try {
const res = await fetch('/api/admin/users/' + userId + '/passkeys/' + passkeyId, { method: 'DELETE' });
const data = await res.json();
if (res.ok) {
showNotice('Passkey deleted', false);
setTimeout(() => window.location.reload(), 800);
} else {
showNotice(data.error || 'Failed to delete passkey', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
/>
</AdminLayout>
);
};

View File

@ -0,0 +1,121 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AdminUsersPage = ({
users,
}: {
users: any[];
}) => {
return (
<AdminLayout title="Manage Users" currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 4px; font-size: 0.9rem;"
/>
<div class="card">
<h2>User Management</h2>
<p>Review and activate pending users.</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.display_name || "-"}</td>
<td>
<span class={`badge badge-${user.account_status}`}>
{user.account_status}
</span>
</td>
<td>
<a
href={`/admin/users/${user.id}`}
class="btn-action"
style="text-decoration: none;"
>
Manage
</a>
{user.account_status === "pending" && (
<button
type="button"
class="btn-action btn-success"
onclick={`updateStatus('${user.id}', 'active')`}
>
Activate
</button>
)}
{user.account_status === "active" && (
<button
type="button"
class="btn-action btn-warning"
onclick={`updateStatus('${user.id}', 'suspended')`}
>
Suspend
</button>
)}
{user.account_status === "suspended" && (
<button
type="button"
class="btn-action btn-success"
onclick={`updateStatus('${user.id}', 'active')`}
>
Re-Activate
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
function showNotice(msg, isError) {
const banner = document.getElementById('status-banner');
banner.textContent = msg;
banner.style.display = 'block';
banner.style.background = isError ? '#f8d7da' : '#d1e7dd';
banner.style.color = isError ? '#842029' : '#0f5132';
banner.style.border = isError ? '1px solid #f5c2c7' : '1px solid #badbcc';
setTimeout(() => { banner.style.display = 'none'; }, 6000);
}
async function updateStatus(userId, status) {
if (!confirm('Are you sure you want to set this user to ' + status + '?')) {
return;
}
try {
const res = await fetch('/api/admin/users/' + userId + '/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (res.ok) {
showNotice('User status updated to ' + status, false);
setTimeout(() => window.location.reload(), 800);
} else {
const data = await res.json();
showNotice(data.error || 'Failed to update status', true);
}
} catch (err) {
showNotice('Network error', true);
}
}
`,
}}
>
</script>
</AdminLayout>
);
};

View File

@ -0,0 +1,52 @@
import { AdminLayout } from "./AdminLayout.tsx";
export const AuditLogPage = ({
logs,
}: {
logs: any[];
}) => {
return (
<AdminLayout title="System Audit Logs" currentPath="/admin/audit-logs">
<div class="card">
<h2>System Audit Logs</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Resource</th>
<th>IP Address</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr>
<td>{new Date(log.created_at).toLocaleString()}</td>
<td>
<strong>{log.action}</strong>
</td>
<td>{log.user || "System"}</td>
<td>{log.resource || "-"}</td>
<td>{log.ip_address || "-"}</td>
<td>
<pre>{log.details ? JSON.stringify(log.details, null, 2) : "{}"}</pre>
</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: "center" }}>
No audit logs found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</AdminLayout>
);
};

View File

@ -0,0 +1,220 @@
export const AuthenticatedLayout = ({
children,
title,
currentPath,
isAdmin = false,
}: {
children: any;
title: string;
currentPath: string;
isAdmin?: boolean;
}) => {
const navItems = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Sessions", href: "/dashboard/sessions" },
{ label: "Passkeys", href: "/dashboard/passkeys" },
...(isAdmin ? [{ label: "Admin Console", href: "/admin/users" }] : []),
];
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} - Auth-Yes</title>
<style>
{`
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f8f9fa;
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
background: #ffffff;
border-bottom: 1px solid #e9ecef;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.header h1 {
margin: 0;
font-size: 1.25rem;
color: #212529;
}
.nav {
display: flex;
gap: 1.5rem;
}
.nav a {
text-decoration: none;
color: #495057;
font-weight: 500;
padding: 0.5rem 0;
}
.nav a:hover {
color: #007bff;
}
.nav a.active {
color: #007bff;
border-bottom: 2px solid #007bff;
}
.logout-btn {
background: none;
border: 1px solid #dc3545;
color: #dc3545;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.logout-btn:hover {
background: #dc3545;
color: white;
}
.main-content {
flex: 1;
padding: 2rem;
max-width: 1000px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 1.5rem;
border: 1px solid #e9ecef;
}
h2 {
margin-top: 0;
color: #343a40;
font-size: 1.25rem;
border-bottom: 1px solid #e9ecef;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.btn-danger {
background: #dc3545;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-danger:hover {
background: #c82333;
}
.btn-primary {
background: #007bff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-primary:hover {
background: #0069d9;
}
.badge {
display: inline-block;
padding: 0.25em 0.4em;
font-size: 75%;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
}
.badge-success { background-color: #28a745; color: white; }
.badge-info { background-color: #17a2b8; color: white; }
.badge-secondary { background-color: #6c757d; color: white; }
`}
</style>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
<script src="/public/auth-client.js"></script>
</head>
<body>
<header class="header">
<h1>Identity Provider</h1>
<nav class="nav">
{navItems.map((item) => {
const isActive = item.href === "/dashboard"
? currentPath === "/dashboard"
: currentPath.startsWith(item.href);
return (
<a
href={item.href}
class={isActive ? "active" : ""}
>
{item.label}
</a>
);
})}
</nav>
<div
style={{
marginLeft: "auto",
marginRight: "1rem",
display: "flex",
gap: "1rem",
}}
>
{isAdmin && (
<a
href="/admin"
class="btn-primary"
style={{ textDecoration: "none" }}
>
Admin Console
</a>
)}
<a
href="/logout"
class="logout-btn"
style={{
textDecoration: "none",
display: "inline-block",
textAlign: "center",
}}
>
Logout
</a>
</div>
</header>
<main class="main-content">
{children}
</main>
</body>
</html>
);
};

37
ui/components/Layout.tsx Normal file
View File

@ -0,0 +1,37 @@
export const Layout = ({
children,
title,
}: {
children: any;
title: string;
}) => {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<style>
{`
body { font-family: sans-serif; background: #f4f4f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #333; text-align: center; }
input { width: 100%; padding: 0.75rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; padding: 0.75rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
button:hover { background: #0056b3; }
.error { color: red; font-size: 0.875rem; margin-top: 0.5rem; text-align: center; }
.success { color: green; font-size: 0.875rem; margin-top: 0.5rem; text-align: center; }
.links { margin-top: 1rem; text-align: center; font-size: 0.875rem; }
.links a { color: #007bff; text-decoration: none; }
.links a:hover { text-decoration: underline; }
`}
</style>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
</head>
<body>
<div class="container">{children}</div>
</body>
</html>
);
};

105
ui/components/LoginPage.tsx Normal file
View File

@ -0,0 +1,105 @@
import { Layout } from "./Layout.tsx";
export const LoginPage = () => {
return (
<Layout title="Login">
<div style={{ textAlign: "center" }}>
<h1 style={{ marginBottom: "0.5rem" }}>Authenticate</h1>
<p style={{ color: "#666", marginBottom: "2rem" }}>
Use your registered hardware key or passkey to log in.
</p>
<div
id="instructionBox"
style={{
background: "#eef2f5",
padding: "1rem",
borderRadius: "6px",
marginBottom: "1.5rem",
fontSize: "0.9rem",
color: "#333",
border: "1px solid #dcdcdc",
}}
>
<p style={{ margin: "0 0 0.5rem 0" }}>
<strong>Instruction:</strong>
</p>
<ul style={{ margin: 0, paddingLeft: "1.5rem", textAlign: "left" }}>
<li>
Insert your hardware token (e.g. YubiKey) into the USB port.
</li>
<li>
Or prepare to scan a QR code if using a mobile device passkey.
</li>
</ul>
</div>
<button
type="button"
id="loginBtn"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "bold",
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
Login with Passkey
</button>
<div
id="loadingIndicator"
style={{
display: "none",
marginTop: "1rem",
color: "#007bff",
fontSize: "0.9rem",
}}
>
Waiting for authenticator... Please follow the prompt.
</div>
<div id="statusMessage" class="error"></div>
<div class="links" style={{ marginTop: "2rem" }}>
Don't have an account? <a href="/register">Register here</a>
</div>
</div>
<script src="/public/auth-client.js"></script>
<script
dangerouslySetInnerHTML={{
__html: `
document.getElementById('loginBtn').addEventListener('click', async () => {
document.getElementById('loadingIndicator').style.display = 'block';
document.getElementById('loginBtn').disabled = true;
document.getElementById('statusMessage').textContent = '';
try {
await startWebAuthnLogin();
} finally {
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
});
`,
}}
>
</script>
</Layout>
);
};

View File

@ -0,0 +1,276 @@
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
export const PasskeysPage = ({
passkeys,
isAdmin = false,
}: {
passkeys: any[];
isAdmin?: boolean;
}) => {
return (
<AuthenticatedLayout
title="Passkeys"
currentPath="/dashboard/passkeys"
isAdmin={isAdmin}
>
<div class="card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2 style={{ margin: 0, border: "none", padding: 0 }}>
Registered Passkeys
</h2>
<button type="button" id="addPasskeyBtn" class="btn-primary">
+ Add New Passkey
</button>
</div>
<p
style={{
color: "#6c757d",
fontSize: "0.9rem",
marginBottom: "1.5rem",
}}
>
Manage your registered hardware tokens. We recommend having at least
two registered.
</p>
<div
id="addPasskeyContainer"
style={{
display: "none",
marginBottom: "1.5rem",
padding: "1rem",
background: "#f8f9fa",
borderRadius: "6px",
border: "1px solid #dee2e6",
}}
>
<h3 style={{ marginTop: 0, fontSize: "1.1rem" }}>
Register New Passkey
</h3>
<p style={{ fontSize: "0.9rem", color: "#6c757d" }}>
Please insert your new hardware token and follow the prompts.
</p>
<div
id="addPasskeyStatus"
style={{ marginBottom: "1rem", fontSize: "0.9rem" }}
>
</div>
<div style={{ display: "flex", gap: "1rem" }}>
<button
type="button"
id="confirmAddPasskeyBtn"
class="btn-primary"
style={{ background: "#28a745" }}
>
Start Registration
</button>
<button
type="button"
id="cancelAddPasskeyBtn"
class="btn-danger"
style={{ background: "#6c757d" }}
>
Cancel
</button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Uses (Counter)</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td
colSpan={3}
style={{ textAlign: "center", padding: "2rem" }}
>
No passkeys found.
</td>
</tr>
)
: (
passkeys.map((passkey) => (
<tr key={passkey.id}>
<td>
<code
style={{
background: "#f1f3f5",
padding: "0.2rem 0.4rem",
borderRadius: "4px",
}}
>
{passkey.id.split("-")[0]}...
</code>
</td>
<td>{passkey.counter}</td>
<td>
<button
type="button"
class="btn-danger revoke-btn"
data-passkey-id={passkey.id}
disabled={passkeys.length <= 1}
title={passkeys.length <= 1
? "Cannot remove last passkey"
: "Remove"}
style={passkeys.length <= 1
? { opacity: 0.5, cursor: "not-allowed" }
: {}}
>
Remove
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{passkeys.length <= 1 && (
<p
style={{
fontSize: "0.85rem",
color: "#dc3545",
marginTop: "1rem",
}}
>
* You must register another passkey before you can remove your
only remaining one.
</p>
)}
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
// Revoke Passkey Logic
document.querySelectorAll('.revoke-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (e.target.disabled) return;
if (!confirm('Are you sure you want to remove this passkey? This action cannot be undone.')) return;
const passkeyId = e.target.getAttribute('data-passkey-id');
const originalText = e.target.textContent;
e.target.textContent = 'Removing...';
e.target.disabled = true;
try {
const res = await fetch(\`/api/passkeys/\${passkeyId}\`, {
method: 'DELETE'
});
if (res.ok) {
window.location.reload();
} else {
const data = await res.json();
alert(data.error || 'Failed to remove passkey');
e.target.textContent = originalText;
e.target.disabled = false;
}
} catch (err) {
alert('An error occurred');
e.target.textContent = originalText;
e.target.disabled = false;
}
});
});
// Add Passkey Logic
const addContainer = document.getElementById('addPasskeyContainer');
const addBtn = document.getElementById('addPasskeyBtn');
const cancelBtn = document.getElementById('cancelAddPasskeyBtn');
const confirmBtn = document.getElementById('confirmAddPasskeyBtn');
const statusDiv = document.getElementById('addPasskeyStatus');
addBtn.addEventListener('click', () => {
addContainer.style.display = 'block';
addBtn.style.display = 'none';
});
cancelBtn.addEventListener('click', () => {
addContainer.style.display = 'none';
addBtn.style.display = 'block';
statusDiv.textContent = '';
});
confirmBtn.addEventListener('click', async () => {
confirmBtn.disabled = true;
statusDiv.textContent = 'Setting up passkey... Follow the prompt on your device.';
statusDiv.style.color = '#007bff';
try {
// 1. Fetch challenge
const resp = await fetch("/api/passkeys/register/challenge", {
method: "POST",
});
const data = await resp.json();
if (!resp.ok) {
statusDiv.textContent = data.error || "Failed to get registration challenge";
statusDiv.style.color = 'red';
confirmBtn.disabled = false;
return;
}
// 2. Pass challenge to authenticator
const { startRegistration } = SimpleWebAuthnBrowser;
let attResp;
try {
attResp = await startRegistration(data.options);
} catch (error) {
statusDiv.textContent = error.message || "Registration failed on device";
statusDiv.style.color = 'red';
confirmBtn.disabled = false;
return;
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/passkeys/register/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
response: attResp,
}),
});
const verificationJSON = await verificationResp.json();
if (verificationJSON.success) {
statusDiv.textContent = "Passkey added successfully! Reloading...";
statusDiv.style.color = 'green';
setTimeout(() => window.location.reload(), 1000);
} else {
statusDiv.textContent = verificationJSON.error || "Registration verification failed";
statusDiv.style.color = 'red';
confirmBtn.disabled = false;
}
} catch (err) {
console.error(err);
statusDiv.textContent = 'An unexpected error occurred.';
statusDiv.style.color = 'red';
confirmBtn.disabled = false;
}
});
`,
}}
>
</script>
</AuthenticatedLayout>
);
};

View File

@ -0,0 +1,111 @@
import { Layout } from "./Layout.tsx";
export const RecoveryPage = () => {
return (
<Layout title="Account Recovery">
<div
class="card"
style="max-width: 400px; margin: 4rem auto; text-align: center;"
>
<h2>Account Recovery</h2>
<p style="color: #6c757d; margin-bottom: 2rem;">
You have been provided with an out-of-band account recovery link.
Please have your new hardware security key ready.
</p>
<form id="recovery-form">
<input type="hidden" id="recovery-code" name="code" />
<button
type="submit"
class="btn-action btn-success"
style="width: 100%; padding: 0.75rem; font-size: 1rem;"
>
Bind New Passkey
</button>
</form>
<div
id="error-message"
style="color: #dc3545; margin-top: 1rem; display: none;"
>
</div>
<div
id="success-message"
style="color: #28a745; margin-top: 1rem; display: none;"
>
Passkey successfully bound! Redirecting to login...
</div>
</div>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
<script
dangerouslySetInnerHTML={{
__html: `
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (!code) {
document.getElementById('error-message').textContent = 'No recovery code found in the URL.';
document.getElementById('error-message').style.display = 'block';
document.getElementById('recovery-form').style.display = 'none';
} else {
document.getElementById('recovery-code').value = code;
}
document.getElementById('recovery-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = e.target.querySelector('button');
const errorDiv = document.getElementById('error-message');
btn.disabled = true;
btn.textContent = 'Processing...';
errorDiv.style.display = 'none';
try {
const challengeRes = await fetch('/api/recovery/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
if (!challengeRes.ok) {
const data = await challengeRes.json();
throw new Error(data.error || 'Failed to get challenge');
}
const { options } = await challengeRes.json();
const { startRegistration } = SimpleWebAuthnBrowser;
const attResp = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch('/api/recovery/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, response: attResp })
});
if (!verifyRes.ok) {
const data = await verifyRes.json();
throw new Error(data.error || 'Failed to verify passkey');
}
document.getElementById('recovery-form').style.display = 'none';
document.getElementById('success-message').style.display = 'block';
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} catch (err) {
errorDiv.textContent = err.message || 'An error occurred during recovery.';
errorDiv.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Bind New Passkey';
}
});
`,
}}
>
</script>
</Layout>
);
};

View File

@ -0,0 +1,164 @@
import { Layout } from "./Layout.tsx";
export const RegisterPage = (
{ initialCode = "" }: { initialCode?: string },
) => {
return (
<Layout title="Register">
<div style={{ textAlign: "center" }}>
<h1 style={{ marginBottom: "0.5rem" }}>Create Account</h1>
<p
style={{ color: "#666", marginBottom: "1.5rem", fontSize: "0.95rem" }}
>
Register a secure hardware token or passkey using your invite code.
</p>
<div style={{ textAlign: "left", marginBottom: "1rem" }}>
<label
for="username"
style={{
display: "block",
marginBottom: "0.25rem",
fontWeight: "bold",
}}
>
Username
</label>
<input
type="text"
id="username"
placeholder="e.g. pilot_alice"
required
autofocus={!initialCode}
/>
</div>
<div style={{ textAlign: "left", marginBottom: "1.5rem" }}>
<label
for="inviteCode"
style={{
display: "block",
marginBottom: "0.25rem",
fontWeight: "bold",
}}
>
Invite Code
</label>
<input
type="text"
id="inviteCode"
placeholder="Invite Code (e.g. 00000000-... or custom code)"
value={initialCode}
required
/>
</div>
<div
id="instructionBox"
style={{
background: "#e8f4fd",
padding: "0.75rem",
borderRadius: "6px",
marginBottom: "1.5rem",
fontSize: "0.85rem",
color: "#0c5460",
border: "1px solid #bee5eb",
textAlign: "left",
}}
>
<strong>Tip:</strong>{" "}
You can use your phone (via QR code / Bluetooth), biometric sensor
(Touch ID, Windows Hello), password manager (1Password, Bitwarden,
Chrome), or USB security key (YubiKey).
</div>
<button
type="button"
id="registerBtn"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "bold",
background: "#28a745",
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
Register Passkey
</button>
<div
id="loadingIndicator"
style={{
display: "none",
marginTop: "1rem",
color: "#28a745",
fontSize: "0.9rem",
}}
>
Setting up passkey... Follow the prompt on your device.
</div>
<div id="statusMessage" class="error" style={{ marginTop: "1rem" }}>
</div>
<div class="links" style={{ marginTop: "2rem" }}>
Already have an account? <a href="/login">Login here</a>
</div>
</div>
<script src="/public/auth-client.js"></script>
<script
dangerouslySetInnerHTML={{
__html: `
// Auto-populate invite code from URL if present
const urlParams = new URLSearchParams(window.location.search);
const codeParam = urlParams.get('code');
if (codeParam) {
const input = document.getElementById('inviteCode');
if (input) {
input.value = codeParam;
document.getElementById('username').focus();
}
}
document.getElementById('registerBtn').addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
const inviteCode = document.getElementById('inviteCode').value.trim();
if (!username || !inviteCode) {
document.getElementById('statusMessage').textContent = "Username and Invite Code are required.";
document.getElementById('statusMessage').className = "error";
return;
}
document.getElementById('loadingIndicator').style.display = 'block';
document.getElementById('registerBtn').disabled = true;
document.getElementById('statusMessage').textContent = '';
try {
await startWebAuthnRegistration(username, inviteCode);
} finally {
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('registerBtn').disabled = false;
}
});
`,
}}
>
</script>
</Layout>
);
};

View File

@ -0,0 +1,127 @@
import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx";
export const SessionsPage = ({
sessions,
currentSessionId,
isAdmin = false,
}: {
sessions: any[];
currentSessionId: string;
isAdmin?: boolean;
}) => {
return (
<AuthenticatedLayout
title="Active Sessions"
currentPath="/dashboard/sessions"
isAdmin={isAdmin}
>
<div class="card">
<h2>Active Sessions</h2>
<p
style={{
color: "#6c757d",
fontSize: "0.9rem",
marginBottom: "1.5rem",
}}
>
Review and revoke active sessions connected to your account.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Status</th>
<th>Created At</th>
<th>Expires At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{sessions.length === 0
? (
<tr>
<td
colSpan={4}
style={{ textAlign: "center", padding: "2rem" }}
>
No active sessions found.
</td>
</tr>
)
: (
sessions.map((session) => {
const isCurrent = session.id === currentSessionId;
return (
<tr key={session.id}>
<td>
{isCurrent
? (
<span class="badge badge-success">
Current Session
</span>
)
: <span class="badge badge-secondary">Active</span>}
</td>
<td>{new Date(session.created_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td>
{!isCurrent && (
<button
type="button"
class="btn-danger revoke-btn"
data-session-id={session.id}
>
Revoke
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
document.querySelectorAll('.revoke-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
const sessionId = e.target.getAttribute('data-session-id');
const originalText = e.target.textContent;
e.target.textContent = 'Revoking...';
e.target.disabled = true;
try {
const res = await fetch(\`/api/sessions/\${sessionId}\`, {
method: 'DELETE'
});
if (res.ok) {
// Reload the page to reflect changes
window.location.reload();
} else {
const data = await res.json();
alert(data.error || 'Failed to revoke session');
e.target.textContent = originalText;
e.target.disabled = false;
}
} catch (err) {
alert('An error occurred');
e.target.textContent = originalText;
e.target.disabled = false;
}
});
});
`,
}}
>
</script>
</AuthenticatedLayout>
);
};

9
ui/deno.json Normal file
View File

@ -0,0 +1,9 @@
{
"name": "@auth-yes/ui",
"version": "0.1.0",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "jsr:@hono/hono@4/jsx"
},
"exports": "./mod.ts"
}

336
ui/mod.ts Normal file
View File

@ -0,0 +1,336 @@
import { Hono } from "jsr:@hono/hono@4";
import { serveStatic } from "jsr:@hono/hono@4/deno";
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
import { sql } from "../server/db.ts";
import { valkey } from "../server/valkey.ts";
import { getAuthenticatedUser, isGlobalAdmin } from "../server/auth-session.ts";
import { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";
import { SessionsPage } from "./components/SessionsPage.tsx";
import { PasskeysPage } from "./components/PasskeysPage.tsx";
import { AuditLogPage } from "./components/AuditLogPage.tsx";
import { AdminUsersPage } from "./components/AdminUsersPage.tsx";
import { AdminUserDetailsPage } from "./components/AdminUserDetailsPage.tsx";
import { AAGUIDPage } from "./components/AAGUIDPage.tsx";
import { RecoveryPage } from "./components/RecoveryPage.tsx";
import { AdminAppsPage } from "./components/AdminAppsPage.tsx";
import { AdminRolesPage } from "./components/AdminRolesPage.tsx";
import { AdminInvitesPage } from "./components/AdminInvitesPage.tsx";
const uiApp: Hono = new Hono();
// Explicit Side Effect: Route rendering
uiApp.get("/", (c) => {
return c.redirect("/login");
});
uiApp.get("/logout", async (c) => {
const sessionId = getCookie(c, "session_id");
if (sessionId) {
try {
await valkey.del(sessionId);
await sql`DELETE FROM sessions WHERE id = ${sessionId}`;
} catch (_e) {
// Best effort cleanup
}
}
const rpID = Deno.env.get("RP_ID") || "";
const cookieDomain = Deno.env.get("COOKIE_DOMAIN") ||
(rpID.includes(".") ? `.${rpID}` : undefined);
if (cookieDomain) {
deleteCookie(c, "session_id", {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
});
}
deleteCookie(c, "session_id", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
});
return c.redirect("/login");
});
uiApp.get("/login", (c) => {
return c.html(LoginPage());
});
uiApp.get("/recovery", (c) => {
return c.html(RecoveryPage());
});
uiApp.get("/register", (c) => {
const initialCode = c.req.query("code") || "";
return c.html(RegisterPage({ initialCode }));
});
uiApp.get("/dashboard", (c) => {
return c.redirect("/dashboard/sessions");
});
uiApp.get("/dashboard/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
const sessions = await sql`
SELECT id, created_at, expires_at
FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
return c.html(
SessionsPage({ sessions, currentSessionId: auth.sessionId, isAdmin }),
);
});
uiApp.get("/dashboard/passkeys", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
const passkeys = await sql`
SELECT id, credential_id, counter
FROM passkeys
WHERE user_id = ${auth.userId}
`;
return c.html(PasskeysPage({ passkeys, isAdmin }));
});
// Admin Routes
uiApp.get("/admin", (c) => {
return c.redirect("/admin/users");
});
uiApp.get("/admin/users", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const users = await sql`
SELECT id, username, display_name, account_status
FROM users
ORDER BY username ASC
`;
return c.html(AdminUsersPage({ users }));
});
uiApp.get("/admin/apps", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const apps = await sql`
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
COUNT(g.id) AS active_grants_count
FROM apps a
LEFT JOIN grants g ON a.id = g.app_id
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
ORDER BY a.created_at ASC
`;
return c.html(AdminAppsPage({ apps }));
});
uiApp.get("/admin/roles", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const roles = await sql`
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
a.name AS app_name
FROM roles r
LEFT JOIN apps a ON r.app_id = a.id
ORDER BY r.app_id NULLS FIRST, r.name ASC
`;
const apps = await sql`
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
`;
return c.html(AdminRolesPage({ roles, apps }));
});
uiApp.get("/admin/invites", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const invites = await sql`
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
a.name AS app_name, a.id AS app_id,
u.username AS used_by_username
FROM invites i
LEFT JOIN apps a ON i.app_id = a.id
LEFT JOIN users u ON i.used_by = u.id
ORDER BY i.created_at DESC
`;
const apps = await sql`
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
`;
const allRoles = await sql`
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
`;
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
});
uiApp.get("/admin/aaguid", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const allowlist = await sql`
SELECT id, aaguid, description, created_at
FROM aaguid_allowlist
ORDER BY created_at DESC
`;
return c.html(AAGUIDPage({ allowlist }));
});
uiApp.get("/admin/users/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/admin/users");
}
const targetUserId = c.req.param("id");
const user = await sql`
SELECT id, username, display_name, account_status
FROM users
WHERE id = ${targetUserId}
`.then((res) => res[0]);
if (!user) {
return c.redirect("/admin/users");
}
const sessions = await sql`
SELECT id, created_at, expires_at
FROM sessions
WHERE user_id = ${targetUserId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
const passkeys = await sql`
SELECT id, credential_id, counter
FROM passkeys
WHERE user_id = ${targetUserId}
`;
const grants = await sql`
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
FROM grants g
JOIN apps a ON g.app_id = a.id
WHERE g.user_id = ${targetUserId}
ORDER BY a.name ASC
`;
const allApps = await sql`
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
`;
const allRoles = await sql`
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
`;
return c.html(
AdminUserDetailsPage({
user,
sessions,
passkeys,
grants,
allApps,
allRoles,
}),
);
});
uiApp.get("/admin/audit-logs", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) {
return c.redirect("/login");
}
const isAdmin = await isGlobalAdmin(auth.userId);
if (!isAdmin) {
return c.redirect("/dashboard");
}
const logs = await sql`
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
FROM audit_records a
LEFT JOIN users u ON a.user_id = u.id
ORDER BY a.created_at DESC
LIMIT 100
`;
return c.html(AuditLogPage({ logs }));
});
// Explicit Side Effect: Serving static assets (client-side JS)
uiApp.get(
"/public/*",
serveStatic({
root: "./ui",
rewriteRequestPath: (path) => path.replace(/^\/public/, "/public"),
}),
);
export { uiApp };

163
ui/public/auth-client.js Normal file
View File

@ -0,0 +1,163 @@
// deno-lint-ignore-file
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser;
function setStatus(msg, isError = false) {
const el = document.getElementById("statusMessage");
if (el) {
el.textContent = msg;
el.className = isError ? "error" : "success";
}
}
async function startWebAuthnRegistration(username, inviteCode) {
setStatus("");
if (!username || !inviteCode) {
setStatus("Username and Invite Code are required.", true);
return;
}
try {
// 1. Fetch challenge from API
const resp = await fetch("/api/register/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, inviteCode }),
});
let data;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Challenge request failed (${resp.status}): ${text}`, true);
return;
}
if (!resp.ok) {
setStatus(data.error || "Failed to get registration challenge", true);
return;
}
// 2. Pass challenge to authenticator
let attResp;
try {
attResp = await startRegistration({ optionsJSON: data.options });
} catch (error) {
if (error.name === "InvalidStateError") {
setStatus("Authenticator was probably already registered.", true);
} else {
setStatus(error.message || "Registration failed on device", true);
}
throw error;
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/register/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
inviteCode,
response: attResp,
}),
});
let verificationJSON;
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Verification failed (${verificationResp.status}): ${text}`,
true,
);
return;
}
if (verificationJSON.success) {
setStatus("Registration successful! You can now log in.");
setTimeout(() => {
globalThis.location.href = "/login";
}, 2000);
} else {
setStatus(
verificationJSON.error || "Registration verification failed",
true,
);
}
} catch (err) {
console.error(err);
}
}
async function startWebAuthnLogin() {
setStatus("");
try {
// 1. Fetch challenge
const resp = await fetch("/api/login/challenge", {
method: "POST",
});
let data;
try {
data = await resp.json();
} catch {
const text = await resp.text().catch(() => "");
setStatus(`Login challenge failed (${resp.status}): ${text}`, true);
return;
}
if (!resp.ok) {
setStatus(data.error || "Failed to get login challenge", true);
return;
}
// 2. Pass challenge to authenticator
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON: data.options });
} catch (error) {
setStatus(error.message || "Authentication failed on device", true);
throw error;
}
// 3. Send response back to verify
const verificationResp = await fetch("/api/login/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
response: asseResp,
}),
});
let verificationJSON;
try {
verificationJSON = await verificationResp.json();
} catch {
const text = await verificationResp.text().catch(() => "");
setStatus(
`Login verification failed (${verificationResp.status}): ${text}`,
true,
);
return;
}
if (verificationJSON.success) {
setStatus("Login successful! Redirecting...");
setTimeout(() => {
globalThis.location.href = "/dashboard";
}, 1000);
} else {
setStatus(verificationJSON.error || "Login verification failed", true);
}
} catch (err) {
console.error(err);
}
}