feat: add endpoint and CLI for users to view their own OIDC claims (#23053)

- Adds a new API endpoint `GET /api/v2/users/oidc-claims` that returns
only the **merged claims** (not the separate id_token/userinfo
breakdown). Scoped exclusively to the authenticated user's own identity
— no user parameter, so users cannot view each other's claims.
- Adds a new CLI command:** `coder users oidc-claims` that hits the
above endpoint.
- The existing owner-only debug endpoint is preserved unchanged for
admins who need the full claim breakdown.


> 🤖 This PR was created with the help of Coder Agents, and will be
reviewed by my human. 🧑‍💻
This commit is contained in:
Cian Johnston
2026-03-18 22:10:04 +00:00
committed by GitHub
parent a6856320f9
commit be1c06dec9
15 changed files with 524 additions and 19 deletions
+11 -10
View File
@@ -8,16 +8,17 @@ USAGE:
Aliases: user
SUBCOMMANDS:
activate Update a user's status to 'active'. Active users can fully
interact with the platform
create Create a new user.
delete Delete a user by username or user_id.
edit-roles Edit a user's roles by username or id
list Prints the list of users.
show Show a single user. Use 'me' to indicate the currently
authenticated user.
suspend Update a user's status to 'suspended'. A suspended user cannot
log into the platform
activate Update a user's status to 'active'. Active users can fully
interact with the platform
create Create a new user.
delete Delete a user by username or user_id.
edit-roles Edit a user's roles by username or id
list Prints the list of users.
oidc-claims Display the OIDC claims for the authenticated user.
show Show a single user. Use 'me' to indicate the currently
authenticated user.
suspend Update a user's status to 'suspended'. A suspended user
cannot log into the platform
———
Run `coder --help` for a list of global options.
+24
View File
@@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder users oidc-claims [flags]
Display the OIDC claims for the authenticated user.
- Display your OIDC claims:
$ coder users oidc-claims
- Display your OIDC claims as JSON:
$ coder users oidc-claims -o json
OPTIONS:
-c, --column [key|value] (default: key,value)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
———
Run `coder --help` for a list of global options.
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"fmt"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) userOIDCClaims() *serpent.Command {
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(
cliui.TableFormat([]claimRow{}, []string{"key", "value"}),
func(data any) (any, error) {
resp, ok := data.(codersdk.OIDCClaimsResponse)
if !ok {
return nil, xerrors.Errorf("expected type %T, got %T", resp, data)
}
rows := make([]claimRow, 0, len(resp.Claims))
for k, v := range resp.Claims {
rows = append(rows, claimRow{
Key: k,
Value: fmt.Sprintf("%v", v),
})
}
return rows, nil
},
),
cliui.JSONFormat(),
)
cmd := &serpent.Command{
Use: "oidc-claims",
Short: "Display the OIDC claims for the authenticated user.",
Long: FormatExamples(
Example{
Description: "Display your OIDC claims",
Command: "coder users oidc-claims",
},
Example{
Description: "Display your OIDC claims as JSON",
Command: "coder users oidc-claims -o json",
},
),
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
),
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
resp, err := client.UserOIDCClaims(inv.Context())
if err != nil {
return xerrors.Errorf("get oidc claims: %w", err)
}
out, err := formatter.Format(inv.Context(), resp)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
type claimRow struct {
Key string `json:"-" table:"key,default_sort"`
Value string `json:"-" table:"value"`
}
+151
View File
@@ -0,0 +1,151 @@
package cli_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestUserOIDCClaims(t *testing.T) {
t.Parallel()
fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
)
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
})
ownerClient := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: cfg,
})
t.Run("OwnClaims", func(t *testing.T) {
t.Parallel()
claims := jwt.MapClaims{
"email": "alice@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
"groups": []string{"admin", "eng"},
}
userClient, loginResp := fake.Login(t, ownerClient, claims)
defer loginResp.Body.Close()
inv, root := clitest.New(t, "users", "oidc-claims", "-o", "json")
clitest.SetupConfig(t, userClient, root)
buf := bytes.NewBuffer(nil)
inv.Stdout = buf
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
require.NoError(t, err)
var resp codersdk.OIDCClaimsResponse
err = json.Unmarshal(buf.Bytes(), &resp)
require.NoError(t, err, "unmarshal JSON output")
require.NotEmpty(t, resp.Claims, "claims should not be empty")
assert.Equal(t, "alice@coder.com", resp.Claims["email"])
})
t.Run("Table", func(t *testing.T) {
t.Parallel()
claims := jwt.MapClaims{
"email": "bob@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
}
userClient, loginResp := fake.Login(t, ownerClient, claims)
defer loginResp.Body.Close()
inv, root := clitest.New(t, "users", "oidc-claims")
clitest.SetupConfig(t, userClient, root)
buf := bytes.NewBuffer(nil)
inv.Stdout = buf
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
require.NoError(t, err)
output := buf.String()
require.Contains(t, output, "email")
require.Contains(t, output, "bob@coder.com")
})
t.Run("NotOIDCUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "users", "oidc-claims")
clitest.SetupConfig(t, client, root)
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "not an OIDC user")
})
// Verify that two different OIDC users each only see their own
// claims. The endpoint has no user parameter, so there is no way
// to request another user's claims by design.
t.Run("OnlyOwnClaims", func(t *testing.T) {
t.Parallel()
aliceClaims := jwt.MapClaims{
"email": "alice-isolation@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
}
aliceClient, aliceLoginResp := fake.Login(t, ownerClient, aliceClaims)
defer aliceLoginResp.Body.Close()
bobClaims := jwt.MapClaims{
"email": "bob-isolation@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
}
bobClient, bobLoginResp := fake.Login(t, ownerClient, bobClaims)
defer bobLoginResp.Body.Close()
ctx := testutil.Context(t, testutil.WaitMedium)
// Alice sees her own claims.
aliceResp, err := aliceClient.UserOIDCClaims(ctx)
require.NoError(t, err)
assert.Equal(t, "alice-isolation@coder.com", aliceResp.Claims["email"])
// Bob sees his own claims.
bobResp, err := bobClient.UserOIDCClaims(ctx)
require.NoError(t, err)
assert.Equal(t, "bob-isolation@coder.com", bobResp.Claims["email"])
})
t.Run("ClaimsNeverNull", func(t *testing.T) {
t.Parallel()
// Use minimal claims — just enough for OIDC login.
claims := jwt.MapClaims{
"email": "minimal@coder.com",
"email_verified": true,
"sub": uuid.NewString(),
}
userClient, loginResp := fake.Login(t, ownerClient, claims)
defer loginResp.Body.Close()
ctx := testutil.Context(t, testutil.WaitMedium)
resp, err := userClient.UserOIDCClaims(ctx)
require.NoError(t, err)
require.NotNil(t, resp.Claims, "claims should never be nil, expected empty map")
})
}
+1
View File
@@ -19,6 +19,7 @@ func (r *RootCmd) users() *serpent.Command {
r.userSingle(),
r.userDelete(),
r.userEditRoles(),
r.userOIDCClaims(),
r.createUserStatusCommand(codersdk.UserStatusActive),
r.createUserStatusCommand(codersdk.UserStatusSuspended),
},
+35
View File
@@ -7870,6 +7870,31 @@ const docTemplate = `{
]
}
},
"/users/oidc-claims": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get OIDC claims for the authenticated user",
"operationId": "get-oidc-claims-for-the-authenticated-user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OIDCClaimsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/users/oidc/callback": {
"get": {
"tags": [
@@ -16886,6 +16911,16 @@ const docTemplate = `{
}
}
},
"codersdk.OIDCClaimsResponse": {
"type": "object",
"properties": {
"claims": {
"description": "Claims are the merged claims from the OIDC provider. These\nare the union of the ID token claims and the userinfo claims,\nwhere userinfo claims take precedence on conflict.",
"type": "object",
"additionalProperties": true
}
}
},
"codersdk.OIDCConfig": {
"type": "object",
"properties": {
+31
View File
@@ -6965,6 +6965,27 @@
]
}
},
"/users/oidc-claims": {
"get": {
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get OIDC claims for the authenticated user",
"operationId": "get-oidc-claims-for-the-authenticated-user",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OIDCClaimsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/users/oidc/callback": {
"get": {
"tags": ["Users"],
@@ -15337,6 +15358,16 @@
}
}
},
"codersdk.OIDCClaimsResponse": {
"type": "object",
"properties": {
"claims": {
"description": "Claims are the merged claims from the OIDC provider. These\nare the union of the ID token claims and the userinfo claims,\nwhere userinfo claims take precedence on conflict.",
"type": "object",
"additionalProperties": true
}
}
},
"codersdk.OIDCConfig": {
"type": "object",
"properties": {
+1
View File
@@ -1496,6 +1496,7 @@ func New(options *Options) *API {
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Post("/logout", api.postLogout)
r.Get("/oidc-claims", api.userOIDCClaims)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.AssignableSiteRoles)
+58
View File
@@ -72,6 +72,64 @@ func (api *API) userDebugOIDC(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, link.Claims)
}
// Returns the merged OIDC claims for the authenticated user.
//
// @Summary Get OIDC claims for the authenticated user
// @ID get-oidc-claims-for-the-authenticated-user
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.OIDCClaimsResponse
// @Router /users/oidc-claims [get]
func (api *API) userOIDCClaims(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
user, err := api.Database.GetUserByID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get user.",
Detail: err.Error(),
})
return
}
if user.LoginType != database.LoginTypeOIDC {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "User is not an OIDC user.",
})
return
}
//nolint:gocritic // GetUserLinkByUserIDLoginType requires reading
// rbac.ResourceSystem. The endpoint is scoped to the authenticated
// user's own identity via apiKey, so this is safe.
link, err := api.Database.GetUserLinkByUserIDLoginType(
dbauthz.AsSystemRestricted(ctx),
database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: database.LoginTypeOIDC,
},
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get user link.",
Detail: err.Error(),
})
return
}
claims := link.Claims.MergedClaims
if claims == nil {
claims = map[string]interface{}{}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OIDCClaimsResponse{
Claims: claims,
})
}
// Returns whether the initial user has been created or not.
//
// @Summary Check initial user created
+22
View File
@@ -339,6 +339,14 @@ type OIDCAuthMethod struct {
IconURL string `json:"iconUrl"`
}
// OIDCClaimsResponse represents the merged OIDC claims for a user.
type OIDCClaimsResponse struct {
// Claims are the merged claims from the OIDC provider. These
// are the union of the ID token claims and the userinfo claims,
// where userinfo claims take precedence on conflict.
Claims map[string]interface{} `json:"claims"`
}
type UserParameter struct {
Name string `json:"name"`
Value string `json:"value"`
@@ -723,6 +731,20 @@ func (c *Client) UserRoles(ctx context.Context, user string) (UserRoles, error)
return roles, json.NewDecoder(res.Body).Decode(&roles)
}
// UserOIDCClaims returns the merged OIDC claims for the authenticated user.
func (c *Client) UserOIDCClaims(ctx context.Context) (OIDCClaimsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/oidc-claims", nil)
if err != nil {
return OIDCClaimsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OIDCClaimsResponse{}, ReadBodyAsError(res)
}
var resp OIDCClaimsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// LoginWithPassword creates a session token authenticating with an email and password.
// Call `SetSessionToken()` to apply the newly acquired token to the client.
func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) {
+14
View File
@@ -5768,6 +5768,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `iconUrl` | string | false | | |
| `signInText` | string | false | | |
## codersdk.OIDCClaimsResponse
```json
{
"claims": {}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------|--------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `claims` | object | false | | Claims are the merged claims from the OIDC provider. These are the union of the ID token claims and the userinfo claims, where userinfo claims take precedence on conflict. |
## codersdk.OIDCConfig
```json
+31
View File
@@ -376,6 +376,37 @@ curl -X GET http://coder-server:8080/api/v2/users/oauth2/github/device \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get OIDC claims for the authenticated user
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/oidc-claims \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/oidc-claims`
### Example responses
> 200 Response
```json
{
"claims": {}
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OIDCClaimsResponse](schemas.md#codersdkoidcclaimsresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## OpenID Connect Callback
### Code samples
+10 -9
View File
@@ -15,12 +15,13 @@ coder users [subcommand]
## Subcommands
| Name | Purpose |
|--------------------------------------------------|---------------------------------------------------------------------------------------|
| [<code>create</code>](./users_create.md) | Create a new user. |
| [<code>list</code>](./users_list.md) | Prints the list of users. |
| [<code>show</code>](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. |
| [<code>delete</code>](./users_delete.md) | Delete a user by username or user_id. |
| [<code>edit-roles</code>](./users_edit-roles.md) | Edit a user's roles by username or id |
| [<code>activate</code>](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform |
| [<code>suspend</code>](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform |
| Name | Purpose |
|----------------------------------------------------|---------------------------------------------------------------------------------------|
| [<code>create</code>](./users_create.md) | Create a new user. |
| [<code>list</code>](./users_list.md) | Prints the list of users. |
| [<code>show</code>](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. |
| [<code>delete</code>](./users_delete.md) | Delete a user by username or user_id. |
| [<code>edit-roles</code>](./users_edit-roles.md) | Edit a user's roles by username or id |
| [<code>oidc-claims</code>](./users_oidc-claims.md) | Display the OIDC claims for the authenticated user. |
| [<code>activate</code>](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform |
| [<code>suspend</code>](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform |
+42
View File
@@ -0,0 +1,42 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# users oidc-claims
Display the OIDC claims for the authenticated user.
## Usage
```console
coder users oidc-claims [flags]
```
## Description
```console
- Display your OIDC claims:
$ coder users oidc-claims
- Display your OIDC claims as JSON:
$ coder users oidc-claims -o json
```
## Options
### -c, --column
| | |
|---------|---------------------------|
| Type | <code>[key\|value]</code> |
| Default | <code>key,value</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.
+14
View File
@@ -4339,6 +4339,20 @@ export interface OIDCAuthMethod extends AuthMethod {
readonly iconUrl: string;
}
// From codersdk/users.go
/**
* OIDCClaimsResponse represents the merged OIDC claims for a user.
*/
export interface OIDCClaimsResponse {
/**
* Claims are the merged claims from the OIDC provider. These
* are the union of the ID token claims and the userinfo claims,
* where userinfo claims take precedence on conflict.
*/
// empty interface{} type, falling back to unknown
readonly claims: Record<string, unknown>;
}
// From codersdk/deployment.go
export interface OIDCConfig {
readonly allow_signups: boolean;