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