Summary
Bring Canopy's authentication model in line with SkyWalking Horizon UI (apache/skywalking-horizon-ui): one auth.backend selector with local, sso (OIDC / OAuth2), and an API-tokens file, plus a break-glass emergency path. Keep Canopy's existing session shape and role check; reuse Horizon's config schema verbatim so operators get one mental model across both UIs.
Background
Canopy currently supports only local users (configured via CANOPY_USERS as JSON/YAML) with two roles: admin and readonly. Horizon has a richer and battle-tested model: local / SSO (OIDC + OAuth2) / API tokens, optional break-glass, sliding cookie sessions, and 13 RBAC verbs gated per route. Adopting that model — instead of inventing a separate LDAP flow — means Canopy benefits from the same security hardening (dummy-hash timing equalization, scope intersection, role re-resolution) and the same configuration vocabulary.
Reference implementation
Repo: https://github.com/apache/skywalking-horizon-ui (default branch main).
Concrete files to mirror (Horizon → Canopy):
| Horizon concept |
Horizon file |
Reuse strategy |
| Backend dispatch (local / sso / break-glass) |
apps/bff/src/http/user.ts |
Same dispatcher pattern in canopy/server/src/routes/auth.ts |
| Local argon2id login (with dummy-hash timing) |
apps/bff/src/user/local.ts |
Replace server/src/routes/auth.ts bcrypt path with argon2id |
| SSO/OIDC identity mapping |
apps/bff/src/user/oidc/identity.ts |
New canopy/server/src/auth/oidc.ts |
API tokens (SHA-256, hzn_ prefix, re-resolved roles) |
apps/bff/src/user/tokens.ts |
New canopy/server/src/auth/tokens.ts |
| Session middleware (cookie + bearer resolution order) |
apps/bff/src/user/middleware.ts |
Extend canopy/server/src/plugins/session.ts |
| In-memory session store |
apps/bff/src/user/sessions.ts |
Replace current session impl |
Cookie defaults (horizon_sid, cookieSecure, ttlMinutes: 60) |
apps/bff/src/config/schema.ts (sessionSchema) |
Use the same defaults |
Three-variant Verified<T> audit taxonomy |
apps/bff/src/user/outcome.ts |
Copy the shape into Canopy |
Config schema (auth.local, auth.sso, auth.breakGlass, auth.tokensFile) |
apps/bff/src/config/schema.ts |
Reuse keys verbatim |
Goals
auth.backend selector: local | sso (default).
auth.local.users[] with argon2id password hashes, defaulting to empty.
auth.sso.providers[] for OIDC (e.g. Google) and OAuth2 (e.g. GitHub) — same provider shape Horizon uses.
auth.sso.roles with defaultRoles + roleByEmail (exact-match) + roleByDomain.
auth.tokensFile for API tokens with SHA-256 secrets + bearer header (Authorization: Bearer hzn_...).
- Optional
auth.breakGlass — only honored when SSO backend reports unhealthy.
- Keep Canopy's existing
admin / readonly role names; map Horizon's viewer/maintainer/operator/admin → Canopy roles inside the BFF.
Tasks
- Research — read Horizon's
apps/bff/src/user/, apps/bff/src/http/user.ts, and apps/bff/src/config/schema.ts; document Canopy's current server/src/routes/auth.ts, server/src/plugins/session.ts, server/src/lib/role.ts.
- Design doc —
canopy/docs/auth-design.md covering:
- Config schema (copy Horizon's
authSchema shape).
- Session cookie + bearer resolution order: OAuth token → API token → cookie session (Horizon order).
- Role re-resolution for bearers (cache 30s success, never cache failure).
- Username-as-credential-anchor: tokens name a principal, never a role set.
- Break-glass gating by SSO health probe.
- PoC — implement, mirroring Horizon file-by-file:
canopy/server/src/auth/local.ts — argon2id + dummy-hash timing.
canopy/server/src/auth/oidc.ts — OIDC + OAuth2 providers.
canopy/server/src/auth/tokens.ts — hzn_ tokens, SHA-256, bearer.
canopy/server/src/auth/sessions.ts — in-memory store.
canopy/server/src/auth/middleware.ts — requireAuth, requireBrowserSession.
canopy/server/src/http/auth.ts — /api/auth/login, /api/auth/me, /api/auth/logout, OIDC routes.
canopy/server/src/config.ts — extend loader for auth.* + session.* + rbac.*.
- Tests — unit tests for each auth backend using mocks (no live IdP); integration test for the bearer-resolution order; tests covering: timing equalization, scope intersection, role re-resolution, break-glass gating.
- Docs — update
canopy/README.md with the new env vars (Horizon-style HORIZON_AUTH_* names kept as CANOPY_AUTH_* for clarity); add canopy/docs/auth-design.md; remove any old LDAP-centric notes from prior docs.
Acceptance Criteria
auth.backend: local with local.users: [] boots cleanly; no default admin.
auth.backend: local with users set issues a horizon_sid-style cookie (httpOnly, sameSite: 'strict', secure: cookieSecure()).
auth.backend: sso with a single GitHub OAuth2 provider works end-to-end (login → consent screen → role resolution via roleByEmail).
- API tokens (
hzn_<id>_<secret>, SHA-256 hashed in tokensFile) authenticate via Authorization: Bearer ...; deleting the local user revokes the token without editing the file.
- Break-glass local admin is honored only when SSO is configured and the SSO health probe fails; every break-glass success logs at WARN with username + IP.
- All existing Canopy e2e tests still pass under
auth.backend: local with one configured admin.
canopy/README.md and canopy/docs/auth-design.md updated.
Out of Scope
- LDAP / SAML (deferred — Horizon does not use it as the primary path either; add later if a user asks).
- Multi-IdP / failover.
- OAuth2.1 server (issuing tokens to MCP clients) — separate issue.
- Password rotation / self-service.
Summary
Bring Canopy's authentication model in line with SkyWalking Horizon UI (
apache/skywalking-horizon-ui): oneauth.backendselector withlocal,sso(OIDC / OAuth2), and an API-tokens file, plus abreak-glassemergency path. Keep Canopy's existing session shape and role check; reuse Horizon's config schema verbatim so operators get one mental model across both UIs.Background
Canopy currently supports only local users (configured via
CANOPY_USERSas JSON/YAML) with two roles:adminandreadonly. Horizon has a richer and battle-tested model: local / SSO (OIDC + OAuth2) / API tokens, optional break-glass, sliding cookie sessions, and 13 RBAC verbs gated per route. Adopting that model — instead of inventing a separate LDAP flow — means Canopy benefits from the same security hardening (dummy-hash timing equalization, scope intersection, role re-resolution) and the same configuration vocabulary.Reference implementation
Repo: https://github.com/apache/skywalking-horizon-ui (default branch
main).Concrete files to mirror (Horizon → Canopy):
apps/bff/src/http/user.tscanopy/server/src/routes/auth.tsapps/bff/src/user/local.tsserver/src/routes/auth.tsbcrypt path with argon2idapps/bff/src/user/oidc/identity.tscanopy/server/src/auth/oidc.tshzn_prefix, re-resolved roles)apps/bff/src/user/tokens.tscanopy/server/src/auth/tokens.tsapps/bff/src/user/middleware.tscanopy/server/src/plugins/session.tsapps/bff/src/user/sessions.tshorizon_sid,cookieSecure,ttlMinutes: 60)apps/bff/src/config/schema.ts(sessionSchema)Verified<T>audit taxonomyapps/bff/src/user/outcome.tsauth.local,auth.sso,auth.breakGlass,auth.tokensFile)apps/bff/src/config/schema.tsGoals
auth.backendselector:local|sso(default).auth.local.users[]with argon2id password hashes, defaulting to empty.auth.sso.providers[]for OIDC (e.g. Google) and OAuth2 (e.g. GitHub) — same provider shape Horizon uses.auth.sso.roleswithdefaultRoles+roleByEmail(exact-match) +roleByDomain.auth.tokensFilefor API tokens with SHA-256 secrets + bearer header (Authorization: Bearer hzn_...).auth.breakGlass— only honored when SSO backend reports unhealthy.admin/readonlyrole names; map Horizon'sviewer/maintainer/operator/admin→ Canopy roles inside the BFF.Tasks
apps/bff/src/user/,apps/bff/src/http/user.ts, andapps/bff/src/config/schema.ts; document Canopy's currentserver/src/routes/auth.ts,server/src/plugins/session.ts,server/src/lib/role.ts.canopy/docs/auth-design.mdcovering:authSchemashape).canopy/server/src/auth/local.ts— argon2id + dummy-hash timing.canopy/server/src/auth/oidc.ts— OIDC + OAuth2 providers.canopy/server/src/auth/tokens.ts—hzn_tokens, SHA-256, bearer.canopy/server/src/auth/sessions.ts— in-memory store.canopy/server/src/auth/middleware.ts—requireAuth,requireBrowserSession.canopy/server/src/http/auth.ts—/api/auth/login,/api/auth/me,/api/auth/logout, OIDC routes.canopy/server/src/config.ts— extend loader forauth.*+session.*+rbac.*.canopy/README.mdwith the new env vars (Horizon-styleHORIZON_AUTH_*names kept asCANOPY_AUTH_*for clarity); addcanopy/docs/auth-design.md; remove any old LDAP-centric notes from prior docs.Acceptance Criteria
auth.backend: localwithlocal.users: []boots cleanly; no default admin.auth.backend: localwith users set issues ahorizon_sid-style cookie (httpOnly,sameSite: 'strict',secure: cookieSecure()).auth.backend: ssowith a single GitHub OAuth2 provider works end-to-end (login → consent screen → role resolution viaroleByEmail).hzn_<id>_<secret>, SHA-256 hashed intokensFile) authenticate viaAuthorization: Bearer ...; deleting the local user revokes the token without editing the file.auth.backend: localwith one configured admin.canopy/README.mdandcanopy/docs/auth-design.mdupdated.Out of Scope