diff --git a/packages/ui/src/Atoms/ThirdParties/Clerk.tsx b/packages/ui/src/Atoms/ThirdParties/Clerk.tsx new file mode 100644 index 000000000..91e98cdf2 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Clerk.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Clerk(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index f3ee9f028..3b0dcf16c 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -18,6 +18,7 @@ import { Anthropic } from "./Anthropic"; import { Asana } from "./Asana"; import { Bitbucket } from "./Bitbucket"; import { Brex } from "./Brex"; +import { Clerk } from "./Clerk"; import { ClickUp } from "./ClickUp"; import { Cloudflare } from "./Cloudflare"; import { Cursor } from "./Cursor"; @@ -55,6 +56,7 @@ const thirdParties: Record>> = { ASANA: Asana, BITBUCKET: Bitbucket, BREX: Brex, + CLERK: Clerk, CLICKUP: ClickUp, CLOUDFLARE: Cloudflare, CURSOR: Cursor, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index b12188d41..3c14999c1 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -2,6 +2,7 @@ export { Anthropic } from "./Anthropic"; export { Asana } from "./Asana"; export { Bitbucket } from "./Bitbucket"; export { Brex } from "./Brex"; +export { Clerk } from "./Clerk"; export { ClickUp } from "./ClickUp"; export { Cloudflare } from "./Cloudflare"; export { Cursor } from "./Cursor"; diff --git a/pkg/accessreview/drivers/clerk.go b/pkg/accessreview/drivers/clerk.go index 0238ab34d..2687825ba 100644 --- a/pkg/accessreview/drivers/clerk.go +++ b/pkg/accessreview/drivers/clerk.go @@ -18,8 +18,8 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" + "strconv" "strings" "time" @@ -49,6 +49,7 @@ type clerkUser struct { BackupCodeEnabled bool `json:"backup_code_enabled"` Banned bool `json:"banned"` Locked bool `json:"locked"` + Deprovisioned bool `json:"deprovisioned"` LastSignInAt *int64 `json:"last_sign_in_at"` CreatedAt int64 `json:"created_at"` EmailAddresses []struct { @@ -57,10 +58,6 @@ type clerkUser struct { } `json:"email_addresses"` } -type clerkUsersEnvelope struct { - Data []clerkUser `json:"data"` -} - func NewClerkDriver(httpClient *http.Client) *ClerkDriver { return &ClerkDriver{ httpClient: &http.Client{ @@ -84,10 +81,6 @@ func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) return nil, err } - if len(users) == 0 { - return records, nil - } - for _, u := range users { email := clerkPrimaryEmail(u) if email == "" { @@ -97,7 +90,7 @@ func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) record := AccountRecord{ Email: email, FullName: clerkFullName(u, email), - Active: new(!u.Banned && !u.Locked), + Active: new(!u.Banned && !u.Locked && !u.Deprovisioned), IsAdmin: false, MFAStatus: clerkMFAStatus(u), AuthMethod: clerkAuthMethod(u), @@ -133,8 +126,8 @@ func (d *ClerkDriver) fetchUsersPage(ctx context.Context, offset int) ([]clerkUs } q := req.URL.Query() - q.Set("limit", fmt.Sprintf("%d", clerkUsersPageSize)) - q.Set("offset", fmt.Sprintf("%d", offset)) + q.Set("limit", strconv.Itoa(clerkUsersPageSize)) + q.Set("offset", strconv.Itoa(offset)) req.URL.RawQuery = q.Encode() req.Header.Set("Accept", "application/json") @@ -152,23 +145,12 @@ func (d *ClerkDriver) fetchUsersPage(ctx context.Context, offset int) ([]clerkUs return nil, fmt.Errorf("cannot fetch clerk users: unexpected status %d", httpResp.StatusCode) } - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("cannot read clerk users response: %w", err) - } - - var paged clerkUsersEnvelope - if err := json.Unmarshal(body, &paged); err != nil { - return nil, fmt.Errorf("cannot decode clerk users response: %w", err) - } - - if paged.Data != nil { - return paged.Data, nil - } - + // GET /v1/users returns a bare JSON array of user objects; the total + // count is exposed separately via /v1/users/count. Decode directly + // into a slice. var users []clerkUser - if err := json.Unmarshal(body, &users); err != nil { - return nil, fmt.Errorf("cannot decode clerk users list response: %w", err) + if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("cannot decode clerk users response: %w", err) } return users, nil diff --git a/pkg/accessreview/drivers/clerk_test.go b/pkg/accessreview/drivers/clerk_test.go index de71ebdee..f233ced77 100644 --- a/pkg/accessreview/drivers/clerk_test.go +++ b/pkg/accessreview/drivers/clerk_test.go @@ -35,29 +35,32 @@ func TestClerkDriver(t *testing.T) { require.NoError(t, err) require.Len(t, records, 3) + // Clerk returns users newest-first (default order_by=-created_at). first := records[0] - assert.Equal(t, "usr_000000000000000000000001", first.ExternalID) - assert.Equal(t, "jane@example.com", first.Email) - assert.Equal(t, "Jane Doe", first.FullName) + assert.Equal(t, "user_3EfkCEWmtIsoMD3rRxIpDsBOPzv", first.ExternalID) + assert.Equal(t, "c@example.com", first.Email) + assert.Equal(t, "c c", first.FullName) + assert.Equal(t, coredata.AccessEntryAccountTypeUser, first.AccountType) require.NotNil(t, first.Active) assert.True(t, *first.Active) assert.Equal(t, coredata.MFAStatusDisabled, first.MFAStatus) assert.Equal(t, coredata.AccessEntryAuthMethodPassword, first.AuthMethod) assert.NotNil(t, first.CreatedAt) - assert.NotNil(t, first.LastLogin) + assert.Nil(t, first.LastLogin) second := records[1] - assert.Equal(t, "developer-user", second.FullName) + assert.Equal(t, "b@example.com", second.Email) + assert.Equal(t, "b b", second.FullName) require.NotNil(t, second.Active) assert.True(t, *second.Active) - assert.Equal(t, coredata.MFAStatusEnabled, second.MFAStatus) - assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, second.AuthMethod) + // a@example.com is locked, so it must be reported inactive. third := records[2] - assert.Equal(t, "blocked@example.com", third.FullName) + assert.Equal(t, "a@example.com", third.Email) + assert.Equal(t, "a a", third.FullName) require.NotNil(t, third.Active) assert.False(t, *third.Active) - assert.Equal(t, coredata.MFAStatusDisabled, third.MFAStatus) + assert.Equal(t, coredata.AccessEntryAuthMethodPassword, third.AuthMethod) } func TestClerkPrimaryEmail(t *testing.T) { diff --git a/pkg/accessreview/drivers/testdata/clerk.yaml b/pkg/accessreview/drivers/testdata/clerk.yaml index d6acf2993..1fdf431aa 100644 --- a/pkg/accessreview/drivers/testdata/clerk.yaml +++ b/pkg/accessreview/drivers/testdata/clerk.yaml @@ -24,14 +24,20 @@ interactions: proto_minor: 0 content_length: -1 uncompressed: true - body: '{"data":[{"id":"usr_000000000000000000000001","primary_email_address_id":"eml_primary_1","username":null,"first_name":"Jane","last_name":"Doe","password_enabled":true,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"banned":false,"locked":false,"last_sign_in_at":1748471521000,"created_at":1748342000000,"email_addresses":[{"id":"eml_secondary_1","email_address":"jane+alt@example.com"},{"id":"eml_primary_1","email_address":"jane@example.com"}]},{"id":"usr_000000000000000000000002","primary_email_address_id":null,"username":"developer-user","first_name":null,"last_name":null,"password_enabled":false,"two_factor_enabled":true,"totp_enabled":false,"backup_code_enabled":false,"banned":false,"locked":false,"last_sign_in_at":null,"created_at":1748343000000,"email_addresses":[{"id":"eml_2","email_address":"developer@example.com"}]},{"id":"usr_000000000000000000000003","primary_email_address_id":"eml_primary_3","username":null,"first_name":null,"last_name":null,"password_enabled":false,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"banned":true,"locked":false,"last_sign_in_at":null,"created_at":1748344000000,"email_addresses":[{"id":"eml_primary_3","email_address":"blocked@example.com"}]}],"total_count":3}' + body: '[{"id":"user_3EfkCEWmtIsoMD3rRxIpDsBOPzv","object":"user","username":null,"first_name":"c","last_name":"c","locale":null,"image_url":"https://img.clerk.com/anonymized","has_image":false,"primary_email_address_id":"idn_3EfkCCq17KQsZzWLhtKnhAlguva","primary_phone_number_id":null,"primary_web3_wallet_id":null,"password_enabled":true,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"email_addresses":[{"id":"idn_3EfkCCq17KQsZzWLhtKnhAlguva","object":"email_address","email_address":"c@example.com","reserved":false,"verification":{"object":"verification_admin","status":"verified","strategy":"admin","attempts":null,"expire_at":null},"linked_to":[],"matches_sso_connection":false,"created_at":1780576881106,"updated_at":1780576881106}],"phone_numbers":[],"web3_wallets":[],"passkeys":[],"external_accounts":[],"saml_accounts":[],"enterprise_accounts":[],"password_last_updated_at":1780576881104,"public_metadata":{},"private_metadata":{},"unsafe_metadata":{},"external_id":null,"last_sign_in_at":null,"banned":false,"locked":false,"lockout_expires_in_seconds":null,"verification_attempts_remaining":100,"created_at":1780576881104,"updated_at":1780576881109,"delete_self_enabled":true,"bypass_client_trust":false,"create_organization_enabled":true,"last_active_at":null,"mfa_enabled_at":null,"mfa_disabled_at":null,"legal_accepted_at":null,"requires_password_reset":false,"deprovisioned":false,"profile_image_url":"https://www.gravatar.com/avatar?d=mp"},{"id":"user_3EfkAUnJSm40lEsbkAuF9xUs5p2","object":"user","username":null,"first_name":"b","last_name":"b","locale":null,"image_url":"https://img.clerk.com/anonymized","has_image":false,"primary_email_address_id":"idn_3EfkAT1dfG5X3qrNnhntuaNC85w","primary_phone_number_id":null,"primary_web3_wallet_id":null,"password_enabled":true,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"email_addresses":[{"id":"idn_3EfkAT1dfG5X3qrNnhntuaNC85w","object":"email_address","email_address":"b@example.com","reserved":false,"verification":{"object":"verification_admin","status":"verified","strategy":"admin","attempts":null,"expire_at":null},"linked_to":[],"matches_sso_connection":false,"created_at":1780576867751,"updated_at":1780576867751}],"phone_numbers":[],"web3_wallets":[],"passkeys":[],"external_accounts":[],"saml_accounts":[],"enterprise_accounts":[],"password_last_updated_at":1780576867746,"public_metadata":{},"private_metadata":{},"unsafe_metadata":{},"external_id":null,"last_sign_in_at":null,"banned":false,"locked":false,"lockout_expires_in_seconds":null,"verification_attempts_remaining":100,"created_at":1780576867746,"updated_at":1780576960608,"delete_self_enabled":true,"bypass_client_trust":false,"create_organization_enabled":true,"last_active_at":null,"mfa_enabled_at":null,"mfa_disabled_at":null,"legal_accepted_at":null,"requires_password_reset":true,"deprovisioned":false,"profile_image_url":"https://www.gravatar.com/avatar?d=mp"},{"id":"user_3Efk88E4dBmhk95VtBSXcOJ9vCx","object":"user","username":null,"first_name":"a","last_name":"a","locale":null,"image_url":"https://img.clerk.com/anonymized","has_image":false,"primary_email_address_id":"idn_3Efk87Z8o0h9gP86FnA8lUC2Alz","primary_phone_number_id":null,"primary_web3_wallet_id":null,"password_enabled":true,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"email_addresses":[{"id":"idn_3Efk87Z8o0h9gP86FnA8lUC2Alz","object":"email_address","email_address":"a@example.com","reserved":false,"verification":{"object":"verification_admin","status":"verified","strategy":"admin","attempts":null,"expire_at":null},"linked_to":[],"matches_sso_connection":false,"created_at":1780576848230,"updated_at":1780576848230}],"phone_numbers":[],"web3_wallets":[],"passkeys":[],"external_accounts":[],"saml_accounts":[],"enterprise_accounts":[],"password_last_updated_at":1780576848227,"public_metadata":{},"private_metadata":{},"unsafe_metadata":{},"external_id":null,"last_sign_in_at":null,"banned":false,"locked":true,"lockout_expires_in_seconds":3554,"verification_attempts_remaining":100,"created_at":1780576848227,"updated_at":1780576947260,"delete_self_enabled":true,"bypass_client_trust":false,"create_organization_enabled":true,"last_active_at":null,"mfa_enabled_at":null,"mfa_disabled_at":null,"legal_accepted_at":null,"requires_password_reset":false,"deprovisioned":false,"profile_image_url":"https://www.gravatar.com/avatar?d=mp"}]' headers: + Cf-Cache-Status: + - DYNAMIC + Clerk-Api-Version: + - "2025-11-10" Content-Type: - application/json Date: - - Fri, 29 May 2026 03:30:00 GMT + - Thu, 04 Jun 2026 12:43:12 GMT Server: - cloudflare + X-Cfworker: + - "1" status: 200 OK code: 200 - duration: 120.5ms + duration: 221.280708ms diff --git a/pkg/connector/provider/clerk.go b/pkg/connector/provider/clerk.go index e2d029925..fa740a65c 100644 --- a/pkg/connector/provider/clerk.go +++ b/pkg/connector/provider/clerk.go @@ -28,9 +28,22 @@ func clerkRegistration() *Registration { Provider: coredata.ConnectorProviderClerk, DisplayName: "Clerk", SupportsAPIKey: true, - // Clerk's Backend API is authenticated with a server-side secret key - // via Authorization: Bearer ; there is no third-party OAuth2 - // flow for account-listing access reviews. + // Clerk's Backend API authenticates with a server-side secret key + // (sk_...) presented as Authorization: Bearer, the default + // APIKeyConnection scheme. There is no third-party OAuth2 flow for + // account-listing: Clerk's OAuth is an end-user IdP (scoped consent + // to a single user's profile), not a partner grant over the Backend + // API. The secret key is bound to one Clerk instance, so there is + // nothing to pick (Pattern 3): no settings struct, no picker, no + // SetOrganizationSettings. + // + // ProbeURL is intentionally empty: the connection probe runs only + // for OAuth2 connections, so it would be dead config for an API-key + // provider; a dead key surfaces on the first ListAccounts instead. + // + // No NewNameResolver: the Backend API exposes no instance/application + // name endpoint reachable with a secret key, so the source keeps its + // generic name (the source-name worker degrades gracefully). NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewClerkDriver(c), nil },