feat: initial commit for auth-yes identity hub
This commit is contained in:
parent
deb6d9309e
commit
ac89e4c8d0
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
spire_ffi/target/
|
||||
.env
|
||||
.DS_Store
|
||||
node_modules/
|
||||
45
Dockerfile
Normal file
45
Dockerfile
Normal 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
455
README.md
Normal 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
35
deno.json
Normal 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
635
infra/setup.ts
Normal 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
5
sdk/deno.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@auth-yes/sdk",
|
||||
"version": "0.1.0",
|
||||
"exports": "./mod.ts"
|
||||
}
|
||||
27
sdk/gen/auth_connect.ts
Normal file
27
sdk/gen/auth_connect.ts
Normal 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
148
sdk/gen/auth_pb.ts
Normal 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
10
sdk/mod.test.ts
Normal 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
204
sdk/mod.ts
Normal 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
24
server/audit.ts
Normal 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
109
server/auth-session.ts
Normal 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
19
server/auth.proto
Normal 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
202
server/db.ts
Normal 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
5
server/deno.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@auth-yes/server",
|
||||
"version": "0.1.0",
|
||||
"exports": "./main.ts"
|
||||
}
|
||||
1974
server/main.ts
Normal file
1974
server/main.ts
Normal file
File diff suppressed because it is too large
Load Diff
58
server/ratelimit.ts
Normal file
58
server/ratelimit.ts
Normal 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
180
server/spire_ffi.ts
Normal 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
25
server/valkey.ts
Normal 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
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
23
spire_ffi/Cargo.toml
Normal 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
9
spire_ffi/build.rs
Normal 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(())
|
||||
}
|
||||
221
spire_ffi/proto/workload.proto
Normal file
221
spire_ffi/proto/workload.proto
Normal 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
167
spire_ffi/src/lib.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
ui/components/AAGUIDPage.tsx
Normal file
167
ui/components/AAGUIDPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
234
ui/components/AdminAppsPage.tsx
Normal file
234
ui/components/AdminAppsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
612
ui/components/AdminInvitesPage.tsx
Normal file
612
ui/components/AdminInvitesPage.tsx
Normal 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;"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
188
ui/components/AdminLayout.tsx
Normal file
188
ui/components/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
341
ui/components/AdminRolesPage.tsx
Normal file
341
ui/components/AdminRolesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
459
ui/components/AdminUserDetailsPage.tsx
Normal file
459
ui/components/AdminUserDetailsPage.tsx
Normal 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;"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
};
|
||||
121
ui/components/AdminUsersPage.tsx
Normal file
121
ui/components/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
ui/components/AuditLogPage.tsx
Normal file
52
ui/components/AuditLogPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
220
ui/components/AuthenticatedLayout.tsx
Normal file
220
ui/components/AuthenticatedLayout.tsx
Normal 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
37
ui/components/Layout.tsx
Normal 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
105
ui/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
276
ui/components/PasskeysPage.tsx
Normal file
276
ui/components/PasskeysPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
ui/components/RecoveryPage.tsx
Normal file
111
ui/components/RecoveryPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
164
ui/components/RegisterPage.tsx
Normal file
164
ui/components/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
127
ui/components/SessionsPage.tsx
Normal file
127
ui/components/SessionsPage.tsx
Normal 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
9
ui/deno.json
Normal 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
336
ui/mod.ts
Normal 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
163
ui/public/auth-client.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user