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:
Aurélien Sibiril
2026-06-04 14:21:21 +02:00
parent e3cf1c7e48
commit 900b0608c1
7 changed files with 66 additions and 43 deletions

View 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>
);
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
},