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
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ SESSION_SECRET=session-secret-change-in-production
# stale bare-name claims are not served from cache.
# JWT_PRIVATE_CLAIM_PREFIX=extra # Default: extra. Example: JWT_PRIVATE_CLAIM_PREFIX=acme

# OIDC Groups Claim — a client granted the `groups` scope receives a top-level
# groups claim (a JSON array of the user's group names) in both the
# authorization_code id_token and the /oauth/userinfo response. Clients without
# the scope see no such claim. Group membership is managed by an admin at
# /admin/groups. Any group whose final emitted value starts with "system:" is
# always dropped (privilege-escalation guard), and creating a "system:"-named
# group is rejected.
#
# OIDC_GROUPS_CLAIM_NAME — the claim key the group list is emitted under.
# Default "groups". Must match ^[a-zA-Z][a-zA-Z0-9_]*$ and must not collide with
# a reserved RFC/OIDC/AuthGate claim key (e.g. sub, iss, email, name).
# OIDC_GROUPS_CLAIM_NAME=groups # Example: OIDC_GROUPS_CLAIM_NAME=roles
#
# OIDC_GROUPS_PREFIX — optional string prepended to every emitted group name
# (e.g. "oidc:"). Default empty. The system: deny-list is applied to BOTH the
# raw name and the prefixed value, so a name or a prefix that resolves to a
# system: value is always dropped.
# OIDC_GROUPS_PREFIX= # Example: OIDC_GROUPS_PREFIX=oidc:

# JWT Token Expiration
# JWT_EXPIRATION=10h # Access token lifetime (default: 10h). Supports Go duration format: 5m, 1h, 10h
# JWT_EXPIRATION_JITTER=30m # Max random jitter added to access token expiry (default: 30m)
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ In addition to Device Code Flow, AuthGate supports Authorization Code Flow with
- `User.IsActive` - Boolean (default `true`) controlling whether a user may log in. Disabling revokes all of the user's tokens and `RequireAuth` clears any live session on the next request. Guards prevent self-disable and disabling the last _active_ admin.
- `OAuthConnection` - Per-user binding to a third-party provider (GitHub, Gitea, GitLab, Microsoft). Stores provider tokens and `LastUsedAt`; admins can list/unlink them per user via `/admin/users/:id/connections`.
- `LoginSession` - Server-side registry of browser/device logins that backs the **Active Sessions** page (`/account/devices`) and remote sign-out. The session cookie carries an opaque SID; the row stores only its SHA-256 hash (`SIDHash`, never the raw SID) plus `UserID`, `IP`, `UserAgent`, parsed `Device`/`Browser`/`OS`, `Status` (`active`/`revoked`), `CreatedAt`, `LastSeenAt`, `ExpiresAt`. `middleware.loadUserFromSession` validates the SID against this table on every authenticated request (gated by `LOGIN_SESSION_TRACKING_ENABLED`): a revoked/missing row logs the user out, a SID-less legacy cookie is lazily backfilled, and `LastSeenAt`/`ExpiresAt` are slid forward on a ~60s throttle. UA parsing uses the pure-Go `github.com/mileusna/useragent` (no cgo). Distinct from `AccessToken` (the "Connected Apps" page) — the two data sources never mix.
- `Group` / `UserGroup` - Admin-managed local groups and user↔group membership (`UserGroup` has a composite `(UserID, GroupID)` primary key; `UserGroup.UserID` is the **string UUID** `User.ID`). Each row carries a `Source` (`local` in M1+M2) and `Editable` flag so future upstream-synced groups can coexist read-only without a schema change. Group names are validated at creation (1–64 chars, no whitespace, `system:` prefix rejected). Powers the OIDC `groups` claim (see Key Features).

### Key Features

Expand Down Expand Up @@ -168,6 +169,15 @@ In addition to Device Code Flow, AuthGate supports Authorization Code Flow with
- Uses OAuth 2.0 authorization code flow
- For Microsoft, `User.Username` derives from `onPremisesSamAccountName` (AD logon name like `mtk12345`) when the tenant is hybrid-synced; otherwise falls back to `mailNickname`, then the email local-part. Existing Microsoft-linked users are re-synced on next login; collisions are skipped (login still succeeds). JWT `sub` remains the user UUID and is unaffected.

**Local Groups & OIDC `groups` Claim**

- Admins create/edit/delete local groups and assign/remove members in the web UI at `/admin/groups`.
- A client granted the **`groups` scope** receives a top-level `groups` claim (a JSON `[]string`) in both the **`authorization_code` id_token** and the **`/oauth/userinfo`** response; clients without the scope see no such claim. Refresh-token and device-code grants do not emit an id_token, so `/oauth/userinfo` (always current DB membership) is the live group source for those.
- The claim name and an optional output prefix are configurable via `OIDC_GROUPS_CLAIM_NAME` (default `groups`) and `OIDC_GROUPS_PREFIX` (default empty). The claim name is shape-validated at startup and rejected if it collides with a reserved RFC/OIDC/AuthGate claim.
- **Privilege-escalation guard**: any group whose final emitted value (after prefixing, case-insensitive) starts with `system:` is dropped at the emission layer, AND creating a `system:`-named group is rejected at the service layer. `services.BuildGroupsClaim(names, prefix)` is the single source of truth (deny-list + prefix + sort + dedup) used by both emission sites.
- The `groups` scope is self-service-allowed (user-registered and DCR clients may request it) but is **not** in the default new-client scope set and is **not** emitted for the `client_credentials` grant (no end-user → no groups).
- Discovery (`/.well-known/openid-configuration`) advertises the `groups` scope and the configured claim name. Large group sets are better served via `/oauth/userinfo` than the id_token (header/cookie limits); a warning is logged when a large set is emitted. See `docs/GROUPS.md`.

**Service-to-Service Authentication**

- Three modes: `none` (default), `simple` (shared secret), `hmac` (signature-based)
Expand Down Expand Up @@ -258,6 +268,15 @@ In addition to Device Code Flow, AuthGate supports Authorization Code Flow with
- `POST /admin/users/:id/connections/:conn_id/delete` - Unlink a specific OAuth connection
- `GET /admin/users/:id/authorizations` - List apps the user has authorized
- `POST /admin/users/:id/authorizations/:uuid/revoke` - Revoke a specific user authorization
- `GET /admin/groups` - List local groups
- `GET /admin/groups/new` - Create group form
- `POST /admin/groups` - Save new group
- `GET /admin/groups/:id` - View group detail (members + add/remove)
- `GET /admin/groups/:id/edit` - Edit group form
- `POST /admin/groups/:id` - Update group
- `POST /admin/groups/:id/delete` - Delete group (cascades memberships)
- `POST /admin/groups/:id/members` - Add a user (by username) to the group
- `POST /admin/groups/:id/members/:user_id/delete` - Remove a user from the group
- `GET /admin/audit` - View audit logs with filtering (admin only)
- `GET /admin/audit/export` - Export audit logs as CSV (admin only)

Expand Down
19 changes: 19 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ JWT_EXPIRATION_JITTER=30m # Max random jitter on access token expiry
# JWT_PRIVATE_CLAIM_PREFIX=extra # Default: extra
# JWT_PRIVATE_CLAIM_PREFIX=acme # → acme_domain, acme_project, acme_service_account

# OIDC Groups Claim
# A client granted the `groups` scope receives a top-level groups claim (a JSON
# array of the user's group names) in both the authorization_code id_token and
# the /oauth/userinfo response. Clients without the scope see no such claim.
# Membership is managed by an admin at /admin/groups. The `groups` scope is
# self-service-allowed (user-registered and dynamically-registered clients may
# request it) but is NOT emitted for the client_credentials grant (no end-user).
# Any group whose final emitted value starts with "system:" is always dropped
# (privilege-escalation guard), and creating a "system:"-named group is rejected.
# For very large group sets, prefer /oauth/userinfo over the id_token (header/
# cookie size limits); a warning is logged when a large set is emitted.
# OIDC_GROUPS_CLAIM_NAME=groups # Claim key (default: groups). Must match
# # ^[a-zA-Z][a-zA-Z0-9_]*$ and not collide
# # with a reserved RFC/OIDC/AuthGate claim.
# OIDC_GROUPS_CLAIM_NAME=roles # → emits "roles": ["..."] instead of "groups"
# OIDC_GROUPS_PREFIX= # Optional prefix on every group name
# # (default: empty). Example below.
# OIDC_GROUPS_PREFIX=oidc: # → "groups": ["oidc:platform_devops"]

# Refresh Token Configuration
REFRESH_TOKEN_EXPIRATION=720h # Refresh token lifetime (default: 30 days)
ENABLE_REFRESH_TOKENS=true # Feature flag to enable/disable refresh tokens
Expand Down
151 changes: 151 additions & 0 deletions docs/GROUPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Groups & the OIDC `groups` Claim

AuthGate supports first-class **local groups** and **user↔group membership** so a
downstream consumer — for example [Pinniped](https://pinniped.dev/) Supervisor
feeding Kubernetes RBAC — can bind a `ClusterRoleBinding` / `RoleBinding` to
`kind: Group` and never touch the cluster when people change teams.

This document covers **M1 (claim plumbing)** and **M2 (admin-managed local
groups)**. Upstream group sync (LDAP / Microsoft Graph / GitHub org-teams) is a
future phase; the data model already carries a per-row `Source` + `Editable` so
synced groups can coexist read-only without a migration.

## What you get

1. Admins create/edit/delete **local** groups and assign/remove members at
`/admin/groups`.
2. A client granted the **`groups` scope** receives a top-level `groups` claim —
a JSON array of the member's group names — in **both** the
`authorization_code` **id_token** and the **`/oauth/userinfo`** response.
Clients without the scope see no such claim.
3. The claim key and an optional output prefix are configurable.
4. Any group resolving to a `system:`-prefixed name is **never** emitted, and a
`system:`-named group cannot be created.

## Enabling it

The `groups` scope is **opt-in per client** and is not in the default new-client
scope set. Register the client with the scope:

- Admin client form (`/admin/clients/new`) → add the `groups` scope chip.
- User self-service (`/apps/new`) and Dynamic Client Registration both accept
`groups` as a standard scope.

The scope must be **registered AND granted** (the user consents on
`/oauth/authorize`) for the claim to appear. Existing clients are unaffected
until an admin adds the scope.

> The `groups` scope is **not** honored for the `client_credentials` grant —
> there is no end user behind a machine identity, so no groups are emitted.

## Configuration

| Env var | Default | Purpose |
| ------------------------ | -------- | ----------------------------------------------------------------------------------------- |
| `OIDC_GROUPS_CLAIM_NAME` | `groups` | Top-level claim key the group list is emitted under. Must match `^[a-zA-Z][a-zA-Z0-9_]*$` and not collide with a reserved RFC/OIDC/AuthGate claim (validated at startup). |
| `OIDC_GROUPS_PREFIX` | _(empty)_| Optional string prepended to every emitted group name (e.g. `oidc:`). |

Both emission sites (id_token and `/oauth/userinfo`) resolve the claim key and
prefix from the same config and run names through the same
`services.BuildGroupsClaim` transform — the `system:` deny-list is applied both
before and after prefixing, then the optional prefix, de-dup, and sort — so the
two views are always identical.

## Managing groups

At `/admin/groups` an admin can:

- **Create** a group. Names are 1–64 characters of letters, digits, underscore,
dot, colon, or hyphen, must start with an alphanumeric, must be unique, and
must **not** start with `system:`.
- **Edit** a group's name/description.
- **Delete** a group (its memberships are cascaded).
- **Add/remove members** by username on the group detail page.

Audit events are emitted for every mutation: `GROUP_CREATED`, `GROUP_UPDATED`,
`GROUP_DELETED`, `GROUP_MEMBER_ADDED`, `GROUP_MEMBER_REMOVED`.

## Claim shape

For a user in groups `mtk_platform_devops` and `mtk_sre` whose client holds the
`groups` scope:

`/oauth/userinfo`:

```json
{
"sub": "…",
"iss": "https://authgate.example.com",
"groups": ["mtk_platform_devops", "mtk_sre"]
}
```

The decoded `id_token` carries the same `groups` array (alongside `sub`, `aud`,
`exp`, …). With `OIDC_GROUPS_PREFIX=oidc:` the values become
`["oidc:mtk_platform_devops", "oidc:mtk_sre"]`. With
`OIDC_GROUPS_CLAIM_NAME=roles` the key becomes `roles`.

> **Large group sets:** the `id_token` travels in headers/cookies, which have
> size limits. For users in many groups, prefer reading groups from
> `/oauth/userinfo` (which always reflects current DB membership). AuthGate logs
> a warning when it emits a large set in an id_token. A hard cap is deferred to a
> follow-up.

## Security: the `system:` deny-list

Kubernetes treats `system:`-prefixed groups (e.g. `system:masters`) as
privileged. AuthGate guards against leaking such names two ways:

1. **Creation** — the group service rejects any name starting with `system:`.
2. **Emission** — `BuildGroupsClaim` drops any value that, **before or after**
prefixing, starts with `system:` (case-insensitive). This holds even for a
row seeded directly into the database, bypassing the creation check.

## Discovery

`/.well-known/openid-configuration` advertises the `groups` scope in
`scopes_supported` and the configured claim key in `claims_supported`.

## Wiring into Pinniped → Kubernetes RBAC

Point a Pinniped Supervisor `OIDCIdentityProvider` at AuthGate and map the
`groups` claim:

```yaml
apiVersion: idp.supervisor.pinniped.dev/v1alpha1
kind: OIDCIdentityProvider
metadata:
name: authgate
namespace: pinniped-supervisor
spec:
issuer: https://authgate.example.com
authorizationConfig:
additionalScopes: [openid, groups, email]
claims:
username: email
groups: groups # ← matches OIDC_GROUPS_CLAIM_NAME (default "groups")
client:
secretName: authgate-client-credentials
```

If you set `OIDC_GROUPS_CLAIM_NAME=roles`, set `claims.groups: roles` to match.

Then bind RBAC to a `kind: Group` whose name is exactly the emitted value:

```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-devops-admins
subjects:
- kind: Group
name: mtk_platform_devops # the AuthGate group name (+ OIDC_GROUPS_PREFIX, if set)
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
```

Moving a person in/out of `mtk_platform_devops` at `/admin/groups` now changes
their cluster access on their next token — no `kubectl` required.
4 changes: 3 additions & 1 deletion internal/bootstrap/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type handlerSet struct {
docs *handlers.DocsHandler
jwks *handlers.JWKSHandler
userAdmin *handlers.UserAdminHandler
groupAdmin *handlers.GroupAdminHandler
dashboard *handlers.DashboardHandler
tokenAdmin *handlers.TokenAdminHandler
userService *services.UserService
Expand Down Expand Up @@ -101,7 +102,7 @@ func initializeHandlers(deps handlerDeps) handlerSet {
deps.cfg,
),
oidc: handlers.NewOIDCHandler(
deps.services.token, deps.services.user,
deps.services.token, deps.services.user, deps.services.group,
deps.cfg, len(jwksHandler.Keys()) > 0,
// LocalTokenProvider always implements GenerateIDToken.
true,
Expand All @@ -123,6 +124,7 @@ func initializeHandlers(deps handlerDeps) handlerSet {
deps.services.token,
deps.services.authorization,
),
groupAdmin: handlers.NewGroupAdminHandler(deps.services.group),
dashboard: handlers.NewDashboardHandler(deps.services.dashboard),
tokenAdmin: handlers.NewTokenAdminHandler(deps.services.token),
userService: deps.services.user,
Expand Down
11 changes: 11 additions & 0 deletions internal/bootstrap/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ func setupAllRoutes(
admin.GET("/users/:id/authorizations", h.userAdmin.ShowUserAuthorizations)
admin.POST("/users/:id/authorizations/:uuid/revoke", h.userAdmin.RevokeUserAuthorization)

// Group management routes
admin.GET("/groups", h.groupAdmin.ShowGroupsPage)
admin.GET("/groups/new", h.groupAdmin.ShowCreateGroupPage)
admin.POST("/groups", h.groupAdmin.CreateGroup)
admin.GET("/groups/:id", h.groupAdmin.ViewGroup)
admin.GET("/groups/:id/edit", h.groupAdmin.ShowEditGroupPage)
admin.POST("/groups/:id", h.groupAdmin.UpdateGroup)
admin.POST("/groups/:id/delete", h.groupAdmin.DeleteGroup)
admin.POST("/groups/:id/members", h.groupAdmin.AddMember)
admin.POST("/groups/:id/members/:user_id/delete", h.groupAdmin.RemoveMember)

// Token management routes
admin.GET("/tokens", h.tokenAdmin.ShowTokensPage)
admin.POST("/tokens/:id/revoke", h.tokenAdmin.RevokeToken)
Expand Down
3 changes: 3 additions & 0 deletions internal/bootstrap/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type serviceSet struct {
authorization *services.AuthorizationService
dashboard *services.DashboardService
loginSession *services.LoginSessionService
group *services.GroupService
}

// initializeServices creates all business logic services
Expand Down Expand Up @@ -91,6 +92,7 @@ func initializeServices(
time.Duration(cfg.SessionMaxAge)*time.Second,
time.Duration(cfg.SessionRememberMeMaxAge)*time.Second,
)
groupService := services.NewGroupService(db, auditService, cfg.OIDCGroupsPrefix)

return serviceSet{
user: userService,
Expand All @@ -100,5 +102,6 @@ func initializeServices(
authorization: authorizationService,
dashboard: dashboardService,
loginSession: loginSessionService,
group: groupService,
}
}
Loading
Loading