diff --git a/cli/testdata/coder_users_--help.golden b/cli/testdata/coder_users_--help.golden
index 949dc97c3b..e78d378c28 100644
--- a/cli/testdata/coder_users_--help.golden
+++ b/cli/testdata/coder_users_--help.golden
@@ -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.
diff --git a/cli/testdata/coder_users_oidc-claims_--help.golden b/cli/testdata/coder_users_oidc-claims_--help.golden
new file mode 100644
index 0000000000..81d11236c6
--- /dev/null
+++ b/cli/testdata/coder_users_oidc-claims_--help.golden
@@ -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.
diff --git a/cli/useroidcclaims.go b/cli/useroidcclaims.go
new file mode 100644
index 0000000000..1307565fdf
--- /dev/null
+++ b/cli/useroidcclaims.go
@@ -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"`
+}
diff --git a/cli/useroidcclaims_test.go b/cli/useroidcclaims_test.go
new file mode 100644
index 0000000000..fe05fd0762
--- /dev/null
+++ b/cli/useroidcclaims_test.go
@@ -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")
+ })
+}
diff --git a/cli/users.go b/cli/users.go
index fa15fcddad..221917ea66 100644
--- a/cli/users.go
+++ b/cli/users.go
@@ -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),
},
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 7bd8121a56..1231da75c7 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -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": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index cbb73ea24c..2313633d51 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -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": {
diff --git a/coderd/coderd.go b/coderd/coderd.go
index 99a2c6cbbd..ad4847e868 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -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)
diff --git a/coderd/users.go b/coderd/users.go
index 79b343525c..64a1508675 100644
--- a/coderd/users.go
+++ b/coderd/users.go
@@ -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
diff --git a/codersdk/users.go b/codersdk/users.go
index 1bffc1beac..75faf95d1b 100644
--- a/codersdk/users.go
+++ b/codersdk/users.go
@@ -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) {
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index f5bd612447..5dd5793d7d 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -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
diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md
index aee912f7ed..79501986c5 100644
--- a/docs/reference/api/users.md
+++ b/docs/reference/api/users.md
@@ -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
diff --git a/docs/reference/cli/users.md b/docs/reference/cli/users.md
index 5f05375e8b..96e6d43335 100644
--- a/docs/reference/cli/users.md
+++ b/docs/reference/cli/users.md
@@ -15,12 +15,13 @@ coder users [subcommand]
## Subcommands
-| Name | Purpose |
-|--------------------------------------------------|---------------------------------------------------------------------------------------|
-| [create](./users_create.md) | Create a new user. |
-| [list](./users_list.md) | Prints the list of users. |
-| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. |
-| [delete](./users_delete.md) | Delete a user by username or user_id. |
-| [edit-roles](./users_edit-roles.md) | Edit a user's roles by username or id |
-| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform |
-| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform |
+| Name | Purpose |
+|----------------------------------------------------|---------------------------------------------------------------------------------------|
+| [create](./users_create.md) | Create a new user. |
+| [list](./users_list.md) | Prints the list of users. |
+| [show](./users_show.md) | Show a single user. Use 'me' to indicate the currently authenticated user. |
+| [delete](./users_delete.md) | Delete a user by username or user_id. |
+| [edit-roles](./users_edit-roles.md) | Edit a user's roles by username or id |
+| [oidc-claims](./users_oidc-claims.md) | Display the OIDC claims for the authenticated user. |
+| [activate](./users_activate.md) | Update a user's status to 'active'. Active users can fully interact with the platform |
+| [suspend](./users_suspend.md) | Update a user's status to 'suspended'. A suspended user cannot log into the platform |
diff --git a/docs/reference/cli/users_oidc-claims.md b/docs/reference/cli/users_oidc-claims.md
new file mode 100644
index 0000000000..a38471b118
--- /dev/null
+++ b/docs/reference/cli/users_oidc-claims.md
@@ -0,0 +1,42 @@
+
+# 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 | [key\|value] |
+| Default | key,value |
+
+Columns to display in table output.
+
+### -o, --output
+
+| | |
+|---------|--------------------------|
+| Type | table\|json |
+| Default | table |
+
+Output format.
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 928318c50e..584da94d1a 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -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;
+}
+
// From codersdk/deployment.go
export interface OIDCConfig {
readonly allow_signups: boolean;