Skip to content

Commit 0e0ff11

Browse files
feat(electron): Introduce Electron SDK (#8786)
Co-authored-by: Jeremy Wright <jeremy@clerk.dev>
1 parent a7f923c commit 0e0ff11

111 files changed

Lines changed: 7684 additions & 21 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/electron': patch
3+
---
4+
5+
Introduce `@clerk/electron` package.

‎.changeset/native-oauth-errors.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Display native OAuth callback errors in the UI.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/shared': patch
4+
'@clerk/ui': patch
5+
---
6+
7+
Add localization support for OAuth access denied errors.

‎packages/clerk-js/src/utils/__tests__/authenticateWithTransport.test.ts‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ describe('_authenticateWithTransport', () => {
1414
getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'),
1515
open: vi.fn().mockResolvedValue({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' }),
1616
};
17-
const resource = { reload: vi.fn().mockResolvedValue(undefined) } as any;
17+
const resource = {
18+
reload: vi.fn().mockResolvedValue(undefined),
19+
create: vi.fn().mockResolvedValue(undefined),
20+
} as any;
1821
const authenticateMethod = vi.fn(async (_params, navigate) => {
1922
navigate(new URL('https://provider.example/auth'));
2023
});
@@ -36,6 +39,7 @@ describe('_authenticateWithTransport', () => {
3639
expect(transport.open).toHaveBeenCalledWith(new URL('https://provider.example/auth'));
3740
expect(resource.reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' });
3841
expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, callbackParams);
42+
expect(resource.create).not.toHaveBeenCalled();
3943
});
4044

4145
it('reloads without a nonce when the callback URL has no nonce', async () => {
@@ -60,6 +64,104 @@ describe('_authenticateWithTransport', () => {
6064
expect(clerk.__internal_handleResourceCallback).toHaveBeenCalledWith(resource, {});
6165
});
6266

67+
it('rejects with a localizable error without reloading when the native callback reports OAuth failure', async () => {
68+
const clerk = makeClerk();
69+
const transport = {
70+
getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'),
71+
open: vi.fn().mockResolvedValue({
72+
callbackUrl: 'myapp://sso-callback?__clerk_status=failed&__clerk_error_code=oauth_access_denied',
73+
}),
74+
};
75+
const resource = {
76+
reload: vi.fn().mockResolvedValue(undefined),
77+
create: vi.fn().mockResolvedValue(undefined),
78+
} as any;
79+
const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth'));
80+
81+
await expect(
82+
_authenticateWithTransport({
83+
clerk: clerk as any,
84+
transport,
85+
resource,
86+
authenticateMethod,
87+
params: {} as any,
88+
callbackParams: {},
89+
}),
90+
).rejects.toMatchObject({
91+
code: 'oauth_access_denied',
92+
clerkRuntimeError: true,
93+
} satisfies Partial<ClerkRuntimeError>);
94+
95+
expect(resource.create).toHaveBeenCalledWith({});
96+
expect(resource.reload).not.toHaveBeenCalled();
97+
expect(clerk.__internal_handleResourceCallback).not.toHaveBeenCalled();
98+
});
99+
100+
it('uses a generic error code for unknown native OAuth callback failure codes', async () => {
101+
const clerk = makeClerk();
102+
const transport = {
103+
getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'),
104+
open: vi.fn().mockResolvedValue({
105+
callbackUrl: 'myapp://sso-callback?__clerk_status=failed&__clerk_error_code=reverification_cancelled',
106+
}),
107+
};
108+
const resource = {
109+
reload: vi.fn().mockResolvedValue(undefined),
110+
create: vi.fn().mockResolvedValue(undefined),
111+
} as any;
112+
const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth'));
113+
114+
await expect(
115+
_authenticateWithTransport({
116+
clerk: clerk as any,
117+
transport,
118+
resource,
119+
authenticateMethod,
120+
params: {} as any,
121+
callbackParams: {},
122+
}),
123+
).rejects.toMatchObject({
124+
code: 'oauth_callback_failed',
125+
clerkRuntimeError: true,
126+
} satisfies Partial<ClerkRuntimeError>);
127+
128+
expect(resource.create).toHaveBeenCalledWith({});
129+
expect(resource.reload).not.toHaveBeenCalled();
130+
expect(clerk.__internal_handleResourceCallback).not.toHaveBeenCalled();
131+
});
132+
133+
it('still surfaces the OAuth failure when resetting the attempt fails', async () => {
134+
const clerk = makeClerk();
135+
const transport = {
136+
getRedirectUrl: vi.fn().mockResolvedValue('myapp://sso-callback'),
137+
open: vi.fn().mockResolvedValue({
138+
callbackUrl: 'myapp://sso-callback?__clerk_status=failed&__clerk_error_code=oauth_access_denied',
139+
}),
140+
};
141+
const resource = {
142+
reload: vi.fn().mockResolvedValue(undefined),
143+
create: vi.fn().mockRejectedValue(new Error('network down')),
144+
} as any;
145+
const authenticateMethod = vi.fn(async (_params, navigate) => navigate('https://provider.example/auth'));
146+
147+
await expect(
148+
_authenticateWithTransport({
149+
clerk: clerk as any,
150+
transport,
151+
resource,
152+
authenticateMethod,
153+
params: {} as any,
154+
callbackParams: {},
155+
}),
156+
).rejects.toMatchObject({
157+
code: 'oauth_access_denied',
158+
clerkRuntimeError: true,
159+
} satisfies Partial<ClerkRuntimeError>);
160+
161+
expect(resource.create).toHaveBeenCalledWith({});
162+
expect(clerk.__internal_handleResourceCallback).not.toHaveBeenCalled();
163+
});
164+
63165
it('propagates transport.open rejection and does not complete the callback', async () => {
64166
const clerk = makeClerk();
65167
const transport = {

‎packages/clerk-js/src/utils/authenticateWithTransport.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,38 @@ type ClerkWithResourceCallback = {
1919
) => Promise<unknown>;
2020
};
2121

22+
const NATIVE_OAUTH_FAILED_STATUS = 'failed';
23+
const NATIVE_OAUTH_ERROR_FALLBACK_CODE = 'oauth_callback_failed';
24+
const NATIVE_OAUTH_ERROR_MESSAGES: Record<string, string> = {
25+
oauth_access_denied: 'You did not grant access to your account.',
26+
};
27+
28+
function getNativeOAuthCallbackFailure(callbackUrl: string): { code: string; message: string } | null {
29+
const searchParams = new URL(callbackUrl).searchParams;
30+
const status = searchParams.get('__clerk_status');
31+
32+
if (status !== NATIVE_OAUTH_FAILED_STATUS) {
33+
return null;
34+
}
35+
36+
const unsafeCode = searchParams.get('__clerk_error_code') || NATIVE_OAUTH_ERROR_FALLBACK_CODE;
37+
const code = NATIVE_OAUTH_ERROR_MESSAGES[unsafeCode] ? unsafeCode : NATIVE_OAUTH_ERROR_FALLBACK_CODE;
38+
39+
return {
40+
code,
41+
message: NATIVE_OAUTH_ERROR_MESSAGES[code] || 'OAuth callback failed.',
42+
};
43+
}
44+
45+
async function resetFailedAttempt(resource: SignInResource | SignUpResource): Promise<void> {
46+
try {
47+
// Both resources accept `{}` to reset the attempt, but their `create` param types differ.
48+
await (resource.create as (params: Record<string, never>) => Promise<unknown>)({});
49+
} catch {
50+
// Best-effort: the OAuth failure is still thrown, so a failed reset just keeps the prior behavior.
51+
}
52+
}
53+
2254
export async function _authenticateWithTransport(opts: {
2355
clerk: ClerkWithResourceCallback;
2456
transport: OAuthTransport;
@@ -41,6 +73,15 @@ export async function _authenticateWithTransport(opts: {
4173
}
4274

4375
const { callbackUrl } = await opts.transport.open(new URL(verificationUrl.toString()));
76+
const failure = getNativeOAuthCallbackFailure(callbackUrl);
77+
78+
if (failure) {
79+
// The failed verification persists on the client and would resurface on the next reload (the native
80+
// flow never navigates away from the card), so reset the attempt before surfacing the error.
81+
await resetFailedAttempt(opts.resource);
82+
throw new ClerkRuntimeError(failure.message, { code: failure.code });
83+
}
84+
4485
const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
4586

4687
if (nonce) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
target/
2+
*.node
3+
artifacts/
4+
# napi-generated type defs for the raw native binding (the public surface is
5+
# the hand-written index.js/index.d.ts loader)
6+
native.d.ts

0 commit comments

Comments
 (0)