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;