Skip to content
Open
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
22 changes: 19 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,32 @@ Storage structs are plain Go (no proto tags); the service layer maps between `st

### Auth / RBAC

Off by default. `AUTH_ENABLED=true` turns on OIDC (Dex in dev, see `dex/` and `SSO-README.md`): tokens arrive as a `Bearer` header or an `auth_token` cookie, are verified against JWKS (`internal/auth/oidc.go`), and `AuthMiddleware` injects email/groups/id into the request context.
Off by default. `AUTH_ENABLED=true` turns on OIDC against **any provider** — Dex in dev (`dex/`),
Entra ID, Keycloak; see `SSO-README.md`. The backend owns the whole confidential authorization-code
flow: `GET /api/v1/auth/login` (`internal/auth/login.go`) mints state, nonce and a PKCE verifier,
redirects to the endpoint found by OIDC **discovery** (`internal/auth/oidc.go`), and the callback
exchanges the code and sets the `auth_token` cookie. The frontend knows only `loginUrl` from
`/api/v1/auth/config` — it never builds a provider URL itself. Tokens then arrive as a `Bearer`
header or that cookie, are verified against JWKS, and `AuthMiddleware` injects email/groups/id into
the request context.

Identity rules live in `internal/auth/config.go`, all read from the environment at call time:
`AUTH_GROUPS_CLAIM` says which claim carries the groups, `AUTH_ADMIN_GROUPS` (alias
`AUTH_ADMIN_GROUP`) and `AUTH_ADMIN_EMAILS` grant admin, `AUTH_ALLOWED_GROUPS` gates access at all.
An identity outside the allowed groups is deliberately treated as **unauthenticated** rather than
rejected outright — it keeps read access and loses every write.

`rbacMiddleware` in `backend/cmd/server/main.go` wraps the whole `/api/` mux and enforces, by URL path and HTTP method:

- GET is always allowed, even unauthenticated
- writes require auth; admins (`AUTH_ADMIN_EMAILS`, or group `AUTH_ADMIN_GROUP`) bypass everything
- writes require auth; admins bypass everything
- non-admins may only PUT/POST their own `/users/{id}` and only create/modify absences whose `userId` is theirs (POST bodies are read and re-wrapped to check this)
- `/events` writes are open to **any authenticated caller** — the one exception, an explicit allow
placed before the default deny
- `/teams`, `/departments`, `/holidays` writes are admin-only
- anything else falls through to the default deny

This authorization logic lives in the HTTP layer, not in the services — the gRPC services themselves are unauthenticated.
This authorization logic lives in the HTTP layer, not in the services — the gRPC services themselves are unauthenticated. `cmd/server/rbac_test.go` stands up a fake OIDC provider (discovery + JWKS) and signs real tokens to pin the combination of group-based roles and those path rules; it is the only test that exercises `rbacMiddleware` end to end.

`UpdateUserRequest` carries both `title` (field 5, the historical name) and `job_profile`
(field 6); the service prefers `job_profile` and falls back to `title`. Before that field existed
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ helm upgrade offly offly/offly
| `AUTH_CLIENT_SECRET` | OIDC client secret, used on the callback exchange | — |
| `AUTH_JWKS_URL` | JWKS endpoint | `<issuer>/keys` |
| `AUTH_JWKS_CACHE_TTL` | JWKS cache lifetime, in seconds | `3600` |
| `AUTH_REDIRECT_URL` | Redirect URI registered at the provider | `http://localhost:8080/api/v1/auth/callback` |
| `AUTH_POST_LOGIN_REDIRECT_URL` | Where the browser lands after login | `http://localhost:3000/` |
| `AUTH_SCOPES` | Space-separated scopes (Entra ID: drop `groups`) | `openid profile email groups` |
| `AUTH_GROUPS_CLAIM` | Claim holding the user's groups | `groups` |
| `AUTH_ADMIN_GROUPS` | Comma-separated groups granted the `admin` role (`AUTH_ADMIN_GROUP` also read) | — |
| `AUTH_ALLOWED_GROUPS` | Comma-separated groups allowed to log in; empty = any authenticated user | — |
| `AUTH_ADMIN_EMAILS` | Comma-separated emails granted the `admin` role (`ADMIN_EMAILS` also read) | — |
| `MCP_ENABLED` | Expose the read-only MCP server at `/mcp` | `false` |

Expand All @@ -194,16 +200,24 @@ back to the default. gRPC always binds to loopback — it is reached only by the

## 🔐 SSO Authentication

Offly supports optional SSO via [Dex](https://dexidp.io) (OIDC/PKCE flow).
Offly authenticates against any **OpenID Connect** provider — [Dex](https://dexidp.io) (bundled
for development), **Microsoft Entra ID**, Keycloak… The backend drives the whole confidential
authorization-code flow (state, nonce, PKCE); the frontend only ever calls `/api/v1/auth/login`.

```
Browser ──PKCE──▶ Dex ──ID Token──▶ Backend ──JWT verify──▶ SQLite
Browser ──login──▶ Backend ──authorize (state, nonce, PKCE)──▶ OIDC provider
◀── callback: code exchange, JWT verify, HttpOnly cookie
```

Roles are granted **by group** (`AUTH_ADMIN_GROUPS`, read from the `AUTH_GROUPS_CLAIM` claim)
and/or by email (`AUTH_ADMIN_EMAILS`).

| Role | Permissions |
|------|------------|
| `admin` | Full access — users, teams, holidays, absences |
| `user` | Read all · Edit own profile & absences only · Add and edit events |
| `admin` | Full access — users, teams, holidays, absences, events |
| `user` | Read all · Edit own profile & absences · Add and edit events |
| Outside `AUTH_ALLOWED_GROUPS` | Login refused (403) |
| Signed out | Read-only (GET) |

See [SSO-README.md](SSO-README.md) for the full configuration guide.

Expand Down
Loading
Loading