Fix Clerk user list decoding and add logo
GET /v1/users returns a bare JSON array, not a {"data":[...]} envelope
(total_count is a separate endpoint), so decode directly into a slice.
The previous envelope-first decode errored on the array and never
reached the fallback, so ListAccounts failed against the real API.
Treat deprovisioned users as inactive. Add the missing Clerk
third-party logo and its wiring, and document why the registration
sets no probe URL or name resolver.
Record the driver-test cassette against a live Clerk development
instance, scrubbed of PII and instance identifiers (emails to
example.com, image_url payloads and CF transport headers dropped).
A locked account verifies the inactive path.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
16
packages/ui/src/Atoms/ThirdParties/Clerk.tsx
Normal file
16
packages/ui/src/Atoms/ThirdParties/Clerk.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Clerk(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="m21.47 20.829-2.881-2.881a.572.572 0 0 0-.7-.084 6.854 6.854 0 0 1-7.081 0 .576.576 0 0 0-.7.084l-2.881 2.881a.576.576 0 0 0-.103.69.57.57 0 0 0 .166.186 12 12 0 0 0 14.113 0 .58.58 0 0 0 .239-.423.576.576 0 0 0-.172-.453Zm.002-17.668-2.88 2.88a.569.569 0 0 1-.701.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0-1.222 3.692 6.86 6.86 0 0 0 .978 3.764.573.573 0 0 1-.083.699l-2.881 2.88a.567.567 0 0 1-.864-.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637-.405.566.566 0 0 1 .232.418.57.57 0 0 1-.168.448Zm-7.118 12.261a3.427 3.427 0 1 0 0-6.854 3.427 3.427 0 0 0 0 6.854Z"
|
||||
fill="#6C47FF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<string, FC<ComponentProps<"svg">>> = {
|
||||
ASANA: Asana,
|
||||
BITBUCKET: Bitbucket,
|
||||
BREX: Brex,
|
||||
CLERK: Clerk,
|
||||
CLICKUP: ClickUp,
|
||||
CLOUDFLARE: Cloudflare,
|
||||
CURSOR: Cursor,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
12
pkg/accessreview/drivers/testdata/clerk.yaml
vendored
12
pkg/accessreview/drivers/testdata/clerk.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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 <sk_...>; 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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user