Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-oidc-browsers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evidence-browser/api": patch
---

Verify Evidence Browser OIDC login against a containerized Dex provider for issue #168, and update generated auth guidance to describe optional OIDC SSO alongside local sessions and API keys.
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ HOSTNAME=0.0.0.0
# Auth
AUTH_SECRET= # openssl rand -base64 32
# AUTH_BYPASS=true # Trusted local/intranet only: skips login/setup and runs all requests as admin.
AUTH_LOCAL_ENABLED=true
# OIDC_ENABLED=true
# OIDC_ISSUER=https://auth.example.com/application/o/evidence-browser/
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_REDIRECT_URI=https://evidence.example.com/api/auth/oidc/callback
# OIDC_SCOPES=openid profile email
# OIDC_GROUPS_CLAIM=groups
# OIDC_ADMIN_GROUP=evidence-browser-admins
# OIDC_ALLOWED_GROUPS=evidence-browser-users,evidence-browser-admins
# OIDC_AUTO_PROVISION=true
# OIDC_LINK_BY_VERIFIED_EMAIL=false
# OIDC_BUTTON_LABEL=Sign in with SSO

# Storage (defaults: local, ./data/bundles — both vars optional)
STORAGE_TYPE=local # local | s3
Expand Down
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Stop dumping CI and agent test results into public HTML reports. Evidence Browse
- **Bundle viewer** — Browse zip bundles with file tree navigation
- **Rich rendering** — Markdown (with embedded images), syntax-highlighted code, image preview
- **Pluggable storage** — Local filesystem or S3/R2-compatible object storage
- **Authentication** — Built-in username/password session auth for admin and API access
- **Authentication** — Built-in username/password auth, API keys, and optional OIDC SSO
- **AI Agent integration** — `/llm.txt` endpoint and MCP server for programmatic access
- **Hierarchical bundle IDs** — `org/repo/pr-42/run-1` maps to nested storage paths
- **First-run setup** — `/setup` guides the initial admin, storage check, and workspace creation
Expand Down Expand Up @@ -117,6 +117,54 @@ eb upload dist/evidence.zip --workspace ci-results --bundle-id "pr-42-run-1"
|----------|---------|-------------|
| `AUTH_SECRET` | `evidence-browser-default-secret-change-me` | Session signing secret (must be explicitly set in production) |
| `AUTH_BYPASS` | `false` | Set to `true` only for trusted local or intranet deployments. All requests run as an admin user, `/setup` is skipped, and no login/API key is required. Do not expose an instance with this enabled to an untrusted network. |
| `AUTH_LOCAL_ENABLED` | `true` | Enables built-in username/password login. Set to `false` only when `OIDC_ENABLED=true`; API keys and active OIDC sessions still work. |
| `OIDC_ENABLED` | `false` | Enables OIDC authorization-code login. Requires issuer, client ID, client secret, and redirect URI. |
| `OIDC_ISSUER` | — | OIDC issuer URL used for discovery, for example `https://auth.example.com/application/o/evidence-browser/` for Authentik. |
| `OIDC_CLIENT_ID` | — | Client ID from the OIDC provider. |
| `OIDC_CLIENT_SECRET` | — | Client secret from the OIDC provider. |
| `OIDC_REDIRECT_URI` | — | Public callback URL, for example `https://evidence.example.com/api/auth/oidc/callback`. |
| `OIDC_SCOPES` | `openid profile email` | Space-separated scopes requested during authorization. Include a provider-specific groups scope only when required. |
| `OIDC_GROUPS_CLAIM` | `groups` | Claim name read as the group list. |
| `OIDC_ADMIN_GROUP` | — | Group whose members are synced as Evidence Browser admins. Other OIDC users are synced as `user`. |
| `OIDC_ALLOWED_GROUPS` | — | Optional comma-separated allowlist. When set, users outside every listed group are rejected. |
| `OIDC_AUTO_PROVISION` | `true` | Creates a passwordless local user on first successful OIDC login. |
| `OIDC_LINK_BY_VERIFIED_EMAIL` | `false` | Links a verified OIDC email to an existing local user when no provider identity exists. |
| `OIDC_BUTTON_LABEL` | `Sign in with SSO` | Login page label for the OIDC button. |

### Authentik OIDC setup

Manual sign-off path recorded against Authentik `2026.8.0`.

1. In Authentik, create a Property Mapping for the admin group claim if the default `groups` claim is not already suitable:
- Scope name: `groups`
- Expression:
```python
return [group.name for group in request.user.ak_groups.all()]
```
2. Create an OAuth2/OpenID Provider:
- Authorization flow: the instance default explicit or implicit consent flow.
- Client type: Confidential.
- Redirect URI: `https://<evidence-browser-host>/api/auth/oidc/callback`.
- Signing key: select the instance default.
- Scopes: include `openid`, `profile`, `email`, and the `groups` mapping.
3. Create an Application and attach the provider.
4. Assign the Application to the users or groups allowed to sign in.
5. Configure Evidence Browser:
```bash
AUTH_SECRET="$(openssl rand -base64 32)"
AUTH_LOCAL_ENABLED=false
OIDC_ENABLED=true
OIDC_ISSUER=https://<authentik-host>/application/o/<slug>/
OIDC_CLIENT_ID=<provider-client-id>
OIDC_CLIENT_SECRET=<provider-client-secret>
OIDC_REDIRECT_URI=https://<evidence-browser-host>/api/auth/oidc/callback
OIDC_GROUPS_CLAIM=groups
OIDC_ADMIN_GROUP=evidence-browser-admins
OIDC_ALLOWED_GROUPS=evidence-browser-users,evidence-browser-admins
```
6. Verify that an `evidence-browser-admins` member lands in `/admin`, and that a user outside `OIDC_ALLOWED_GROUPS` is rejected with the safe login error.

Upgrade note: migration v1 rebuilds the `users` table. Back up `evidence.db` before upgrading an existing instance.

### Storage

Expand Down
8 changes: 8 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
services:
oidc:
image: dexidp/dex:v2.44.0
command: ["dex", "serve", "/etc/dex/config.yaml"]
ports:
- "5556:5556"
volumes:
- ./tests/oidc/dex-config.yaml:/etc/dex/config.yaml:ro

minio:
image: minio/minio
command: server /data --console-address ":9001"
Expand Down
5 changes: 3 additions & 2 deletions docs/ACCEPTANCE_CRITERIA.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,9 @@ fixture-unicode.zip
- **Given** AUTH_BYPASS=false이고 OIDC provider가 설정됨
- **When** `/login` 페이지에서 로그인 버튼을 클릭한다
- **Then** OIDC provider의 인증 페이지로 리다이렉트되고, 인증 후 원래 페이지로 돌아온다
- **검증**: docker compose로 mock OIDC provider 구동 후 전체 플로우 테스트
- **참고**: 이 AC는 실제 OIDC provider(Authentik 등) 연동 시 검증. 로컬에서는 AC-03으로 우회 가능
- **검증**: Verified — `packages/web/e2e-oidc/oidc-flow.spec.ts`가 `docker-compose.test.yml`의 Dex provider로 authorization-code flow를 실행한다.
- **Evidence**: `.evidence/20260821-0216-feat-168-oidc-idp-stack-attempt1/` (PR #178 QA bundle; uploaded locally as `/w/default/b/20260821-0216-feat-168-oidc-idp-stack-attempt1`)
- **참고**: 실제 Authentik sign-off 절차는 `README.md`의 "Authentik OIDC setup"에 기록됨.

#### AC-05: 로그아웃

Expand Down
11 changes: 10 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ Vite dev serves the SPA on port 3000 and proxies `/api` to the Hono dev server o

## 4. Auth And Authorization

Evidence Browser uses its own session and API-key model, not NextAuth/OIDC.
Evidence Browser uses its own session and API-key model. Optional OIDC is an
SSO front door onto that same model: successful provider callbacks resolve or
provision a local `users` row, link an `identities` row, and then issue the same
`evidence_session` cookie used by password login. Evidence Browser does not use
NextAuth.

```text
POST /api/auth/login
Expand All @@ -146,6 +150,11 @@ POST /api/auth/login

Auth bypass is isolated in `packages/api/src/lib/auth/bypass.ts` for local/dev workflows and emits a warning when enabled.

OIDC routes live in `packages/api/src/routes/auth.ts` and provider logic lives in
`packages/api/src/lib/auth/oidc.ts`. `OIDC_ADMIN_GROUP` maps group members to the
local `admin` role, while `OIDC_ALLOWED_GROUPS` rejects users outside the
configured allowlist before local provisioning.

---

## 5. Data And Storage
Expand Down
2 changes: 1 addition & 1 deletion docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ manifest.json 은 viewer가 bundle을 여는 데 필요한 최소 정보만 가
### 확장성
- 다양한 에이전트와 파일 구조를 수용할 수 있어야 한다
- manifest는 얇게 유지하고, bundle 내부 파일 구조는 자유로워야 한다
- OIDC 같은 외부 IdP 연동은 향후 확장 방향으로 둘 수 있어야 한다
- 외부 IdP는 OIDC authorization-code 로그인으로 연동할 수 있어야 하며, 기존 session/API-key 모델과 함께 동작해야 한다

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"test:api": "npm -w @evidence-browser/api run test",
"test:cli": "npm -w evidence-browser-cli run test",
"test:web": "npm -w @evidence-browser/web run test",
"e2e": "npm run build && npm -w @evidence-browser/web run e2e",
"e2e": "npm run build && npm -w @evidence-browser/web run e2e && npm -w @evidence-browser/web run e2e:oidc",
"e2e:smoke": "npm run build && npm -w @evidence-browser/web run e2e:smoke",
"build:shared": "npm -w @evidence-browser/shared run build",
"test:shared": "npm -w @evidence-browser/shared run test",
Expand Down
23 changes: 23 additions & 0 deletions packages/api/src/lib/auth/oidc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,29 @@ describe("resolveOidcUser", () => {
expect(findUserByUsername("member-2")?.password).toBeNull();
});

it("reuses the same user row on a second login for the provider subject", async () => {
const first = await resolveOidcUser(claims());
const second = await resolveOidcUser(claims());

expect(second).toMatchObject({
id: first.id,
username: first.username,
role: first.role,
});
expect(
(testDb.prepare(`SELECT COUNT(*) AS count FROM users`).get() as { count: number }).count
).toBe(1);
});

it("provisions admin users from the configured admin group", async () => {
setBaseEnv({ OIDC_ADMIN_GROUP: "admins" });

await expect(resolveOidcUser(claims({ groups: ["users", "admins"] }))).resolves.toMatchObject({
username: "member",
role: "admin",
});
});

it("rejects when no identity, link, or provision rule matches", async () => {
setBaseEnv({
OIDC_AUTO_PROVISION: "false",
Expand Down
17 changes: 16 additions & 1 deletion packages/api/src/lib/auth/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ function uniqueUsername(preferred: string | undefined, sub: string): string {
return `${base.slice(0, 48)}-${randomUUID().slice(0, 8)}`;
}

function isLoopbackIssuer(issuer: string): boolean {
try {
const hostname = new URL(issuer).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In Node, new URL(...).hostname for an IPv6 literal always includes the brackets (new URL('http://[::1]:5556').hostname === '[::1]'), so this hostname === "::1" branch never actually matches — the bracketed check right below already covers the real case. Harmless dead branch, not a functional bug; could drop this line for clarity.


Generated by Claude Code

hostname === "[::1]"
);
} catch {
return false;
}
}

async function getOidcConfig(): Promise<OidcConfig> {
const env = getEnv();
const issuer = env.OIDC_ISSUER;
Expand Down Expand Up @@ -162,7 +176,8 @@ async function getOidcConfig(): Promise<OidcConfig> {
redirect_uris: [redirectUri],
response_types: ["code"],
},
client.ClientSecretPost(clientSecret)
client.ClientSecretPost(clientSecret),
isLoopbackIssuer(issuer) ? { execute: [client.allowInsecureRequests] } : undefined
);
cachedDiscovery = { issuer, clientId, redirectUri, config };
return config;
Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/lib/mcp/llm-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ Use the web UI at /w/{workspace-slug} — drag and drop a .zip file.

## Authentication

Evidence Browser uses local password auth (no OIDC).
Login: POST /api/auth/login with { "username", "password" }
The response sets an evidence_session cookie.
Evidence Browser uses local password auth and optional OIDC SSO.
Password login: POST /api/auth/login with { "username", "password" }
OIDC login: GET /api/auth/oidc/start, then provider callback.
Both paths set an evidence_session cookie. API keys remain valid for automation.

---

Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/lib/mcp/llm-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,10 @@ Use the web UI at /w/{workspace-slug} — drag and drop a .zip file.

## Authentication

Evidence Browser uses local password auth (no OIDC).
Login: POST /api/auth/login with { "username", "password" }
The response sets an evidence_session cookie.
Evidence Browser uses local password auth and optional OIDC SSO.
Password login: POST /api/auth/login with { "username", "password" }
OIDC login: GET /api/auth/oidc/start, then provider callback.
Both paths set an evidence_session cookie. API keys remain valid for automation.

---

Expand Down
Loading
Loading