Authentication & Authorization¶
This page covers how Team Health Check authenticates users and enforces role-based access control (RBAC).
Authentication Methods¶
Team Health Check supports two authentication methods simultaneously. Both coexist — enabling one does not disable the other.
| Method | Description | When to use |
|---|---|---|
| Username / password | Credentials stored in users.password_hash (bcrypt) |
Default; always available |
| SSO / OIDC (PKCE) | Authorization Code flow with PKCE; no client secret required | Enabled by setting OAuth env vars |
Username / Password Flow¶
1. User submits credentials → POST /api/v1/auth/login
2. auth_handler.go calls UserRepository.FindByUsername()
3. bcrypt.CompareHashAndPassword(storedHash, submittedPassword)
4. On match: backend issues a JWT access token + refresh token
5. Frontend stores accessToken/refreshToken in localStorage, and the full
user object (URL-encoded JSON) in a `user` cookie (js-cookie, 7-day
expiry — max-age=604800)
6. Every API request goes through `authenticatedFetch()`, which attaches
`Authorization: Bearer <accessToken>` and, on a 401, transparently calls
POST /api/v1/auth/refresh and retries the request once
7. middleware.ts reads the `user` cookie on every page load for route
protection; missing/invalid cookie → redirect to /login
Password hashing: bcrypt with bcrypt.DefaultCost (cost factor 10, Go golang.org/x/crypto/bcrypt). Passwords are never stored in plain text.
Demo passwords: All demo accounts use the string "demo" as the password (hashed). Admin uses "admin". The admin account is seeded by migration 000007 (runs in every environment); the remaining demo accounts (vp, director1, manager1, teamlead1, demo, etc.) are created by SeedDemoData() at application startup, which only runs when APP_ENV=demo. Never use demo credentials in production.
Token & Session Storage¶
| Item | Storage | Format / Key | Expiry | Purpose |
|---|---|---|---|---|
| Access Token | localStorage |
Key: accessToken |
JWT expiry (from backend) | API requests authorization |
| Refresh Token | localStorage |
Key: refreshToken |
Long-lived | Refreshing access token via POST /api/v1/auth/refresh |
| User Session Cookie | Cookie | Key: user (URL-encoded JSON) |
7 days (max-age=604800) |
Middleware route protection |
SSO / OIDC Flow (Authorization Code + PKCE)¶
Team Health Check supports any OIDC-compliant provider: Keycloak, Okta, Auth0, Google, Azure AD, etc.
1. Frontend fetches SSO config dynamically from GET /api/v1/config
2. User clicks "Sign in with SSO" on /login
3. Frontend generates PKCE code_verifier + code_challenge
4. Frontend redirects to provider's /authorize endpoint:
?client_id=...
&redirect_uri=.../auth/callback
&response_type=code
&scope=openid email profile
&code_challenge=...
&code_challenge_method=S256
5. User authenticates at provider
6. Provider redirects to /auth/callback?code=...
7. Frontend calls POST /api/v1/auth/sso/callback
{ code, code_verifier, redirect_uri }
8. Backend calls provider's token endpoint to exchange code → tokens
9. Backend extracts `email` from ID token claims
10. Backend looks up user by email in users table
11. If found: return JWT access token, refresh token, and user info
12. If not found: return 401 "SSO account not provisioned"
(Team Health Check does not auto-create users from SSO logins)
Configure SSO¶
SSO configuration is configured on the backend (backend/.env):
OAUTH_CLIENT_ID=your-client-id
OAUTH_AUTHORIZE_URL=https://your-provider.com/oauth/authorize
OAUTH_TOKEN_URL=https://your-provider.com/oauth/token
OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback
OAUTH_SCOPES=openid email profile
The frontend fetches these configuration parameters at runtime via GET /api/v1/config. If OAUTH_CLIENT_ID is not set on the backend, the "Sign in with SSO" button is automatically hidden.
Provider setup quick reference¶
| Setting | Value |
|---|---|
| Application type | Single Page App (SPA) / Public client |
| Client secret | Not required |
| Allowed redirect URI | http://localhost:3000/auth/callback (or your domain) |
| Required scopes | openid email profile |
| Token claim needed | email in ID token |
Role-Based Access Control (RBAC)¶
Hierarchy Levels¶
Permissions are tied to hierarchy_levels, not to individual users. Each level has boolean flags (see DEFAULT_ORG_CONFIG in frontend/lib/org-config.ts):
| Permission flag | VP (L1) | Director (L2) | Manager (L3) | Team Lead (L4) | Team Member (L5) |
|---|---|---|---|---|---|
canViewAllTeams |
true | true | false | false | false |
canEditTeams |
true | true | true | false | false |
canManageUsers |
true | true | false | false | false |
canConfigureSystem |
true | false | false | false | false |
canViewReports |
true | true | true | true | false |
canExportData |
true | true | true | true | false |
These defaults can be modified via the Admin panel (Hierarchy Levels tab) without a code change.
isAdmin is a separate boolean on the User record (hierarchyLevelId === 'level-admin') — it is not a hierarchyLevels entry and is not part of this permission table. Admin users bypass hierarchy-level permission checks entirely.
canTakeSurvey is likewise not a hierarchy-level permission — it's a per-user boolean (user.canTakeSurvey) that frontend/middleware.ts checks directly to gate access to /survey.
Route Protection¶
frontend/middleware.ts enforces routes based on the user cookie (simplified):
// Simplified logic — see frontend/middleware.ts for the real implementation
const userCookie = request.cookies.get('user');
if (!userCookie && !isPublicPath) return redirect('/login');
const user = JSON.parse(userCookie.value);
// On /login, redirect to the dashboard for the user's hierarchy level
// (admin -> /admin, L1-L3 -> /manager, L4 -> /dashboard, L5 -> /home)
// On /survey, redirect away unless the per-user flag allows it
if (path === '/survey' && user.canTakeSurvey !== true) {
return redirect(/* role-appropriate dashboard */);
}
Team Access Control¶
A user can access a team's data if any of the following is true:
canViewAllTeamsis true for their hierarchy level (VP, Director; Admin bypasses this check entirely)- The user is a member of the team (
team.members) - The user appears in the team's
supervisorChain
Logic implemented in frontend/lib/org-config.ts:
export function canUserAccessTeam(user: User, team: Team): boolean {
const permissions = getUserPermissions(user);
if (permissions.canViewAllTeams) return true;
if (team.members.includes(user.id)) return true;
return team.supervisorChain.some(s => s.userId === user.id);
}
Password Reset¶
Team Health Check implements a short-lived (1-hour), single-use token flow:
1. User submits email on /forgot-password
2. Backend creates a password_reset_tokens row (token_hash stored, not plaintext)
3. Email sent with reset link (token in URL)
4. User clicks link → token verified → password updated → token marked used
Token table: password_reset_tokens — see DB Schema.
Backend API Authorization¶
The JWT/RBAC checks above are frontend route protection (which page loads). The Go API also enforces authorization independently at the handler level via middleware in backend/interfaces/middleware/jwt_auth.go:
| Middleware | Enforces |
|---|---|
JWTAuthMiddleware |
Valid, non-expired access token required |
OptionalJWTAuthMiddleware |
Attaches user context if a token is present, but doesn't require one |
AdminOnlyMiddleware |
Caller's hierarchy level is level-1 or level-admin (hardcoded level IDs, not the canConfigureSystem permission flag) |
ManagerOrAboveMiddleware |
Caller's hierarchy level is level-1, level-2, level-3, or level-admin (hardcoded level IDs) |
SameUserOrManagerMiddleware |
Caller is the target user or their manager |
TeamMembershipMiddleware |
Caller is a member of, or supervisor over, the :teamId route param |
The full set of public (no-JWT) /api/v1/* routes is: /api/v1/auth/login, /api/v1/auth/refresh, /api/v1/auth/logout (backend/interfaces/api/v1/auth_routes.go); /api/v1/auth/sso/callback and /api/v1/config (backend/interfaces/api/v1/sso_routes.go); and /api/v1/auth/forgot-password, /api/v1/auth/reset-password (backend/interfaces/api/v1/password_reset_handler.go). Every other /api/v1/* route requires a valid JWT.
Known Gaps¶
- The password reset endpoint (
/forgot-password) is not yet rate-limited. - The
usercookie does not setHttpOnlyorSecure— it's read by client-side JS (middleware.ts,frontend/lib/auth.ts) and is not intended to hold the JWTs themselves.