Skip to content

Commit 74cc6f5

Browse files
vdsbenoitnicknisi
andauthored
Improve compatibility with hybrid mobile apps (#84)
Co-authored-by: Nick Nisi <nick.nisi@workos.com>
1 parent 9776265 commit 74cc6f5

File tree

4 files changed

+326
-9
lines changed

4 files changed

+326
-9
lines changed

src/create-client.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,94 @@ describe("create-client", () => {
326326
});
327327
});
328328

329+
describe("getSignInUrl", () => {
330+
beforeEach(() => {
331+
mockLocation();
332+
});
333+
334+
afterEach(() => {
335+
restoreLocation();
336+
});
337+
338+
it("generates a PKCE challenge and returns the AuthKit sign-in page URL", async () => {
339+
const { scope } = nockRefresh();
340+
expect(sessionStorage.getItem(storageKeys.codeVerifier)).toBeNull();
341+
342+
client = await createClient("client_123abc", {
343+
redirectUri: "https://example.com/",
344+
});
345+
const signInUrl = await client.getSignInUrl();
346+
const url = new URL(signInUrl);
347+
348+
// Location.assign should not be called
349+
expect(jest.mocked(location.assign)).not.toHaveBeenCalled();
350+
expect({
351+
url,
352+
searchParams: Object.fromEntries(url.searchParams.entries()),
353+
}).toEqual({
354+
url: expect.objectContaining({
355+
origin: "https://api.workos.com",
356+
pathname: "/user_management/authorize",
357+
}),
358+
searchParams: {
359+
client_id: "client_123abc",
360+
code_challenge: expect.any(String),
361+
code_challenge_method: "S256",
362+
provider: "authkit",
363+
redirect_uri: "https://example.com/",
364+
response_type: "code",
365+
screen_hint: "sign-in",
366+
},
367+
});
368+
expect(sessionStorage.getItem(storageKeys.codeVerifier)).toBeDefined();
369+
scope.done();
370+
});
371+
});
372+
373+
describe("getSignUpUrl", () => {
374+
beforeEach(() => {
375+
mockLocation();
376+
});
377+
378+
afterEach(() => {
379+
restoreLocation();
380+
});
381+
382+
it("generates a PKCE challenge and returns the AuthKit sign-up page URL", async () => {
383+
const { scope } = nockRefresh();
384+
expect(sessionStorage.getItem(storageKeys.codeVerifier)).toBeNull();
385+
386+
client = await createClient("client_123abc", {
387+
redirectUri: "https://example.com/",
388+
});
389+
const signUpUrl = await client.getSignUpUrl();
390+
const url = new URL(signUpUrl);
391+
392+
// Location.assign should not be called
393+
expect(jest.mocked(location.assign)).not.toHaveBeenCalled();
394+
expect({
395+
url,
396+
searchParams: Object.fromEntries(url.searchParams.entries()),
397+
}).toEqual({
398+
url: expect.objectContaining({
399+
origin: "https://api.workos.com",
400+
pathname: "/user_management/authorize",
401+
}),
402+
searchParams: {
403+
client_id: "client_123abc",
404+
code_challenge: expect.any(String),
405+
code_challenge_method: "S256",
406+
provider: "authkit",
407+
redirect_uri: "https://example.com/",
408+
response_type: "code",
409+
screen_hint: "sign-up",
410+
},
411+
});
412+
expect(sessionStorage.getItem(storageKeys.codeVerifier)).toBeDefined();
413+
scope.done();
414+
});
415+
});
416+
329417
describe("signOut", () => {
330418
beforeEach(() => {
331419
mockLocation();
@@ -384,6 +472,92 @@ describe("create-client", () => {
384472
});
385473
});
386474

475+
describe("when the `navigate` option is set to false", () => {
476+
it("makes a fetch request instead of redirecting", async () => {
477+
const { scope } = nockRefresh();
478+
479+
client = await createClient("client_123abc", {
480+
redirectUri: "https://example.com/",
481+
});
482+
483+
const originalFetch = global.fetch;
484+
const mockFetch = jest.fn().mockResolvedValue({
485+
ok: true,
486+
});
487+
global.fetch = mockFetch;
488+
489+
try {
490+
await client.signOut({ navigate: false });
491+
492+
// Location.assign should not be called
493+
expect(jest.mocked(location.assign)).not.toHaveBeenCalled();
494+
495+
// Fetch should be called with the correct URL
496+
expect(mockFetch).toHaveBeenCalledTimes(1);
497+
498+
const fetchUrl = new URL(mockFetch.mock.calls[0][0]);
499+
expect({
500+
fetchUrl,
501+
searchParams: Object.fromEntries(fetchUrl.searchParams.entries()),
502+
}).toEqual({
503+
fetchUrl: expect.objectContaining({
504+
origin: "https://api.workos.com",
505+
pathname: "/user_management/sessions/logout",
506+
}),
507+
searchParams: { session_id: "session_123abc" },
508+
});
509+
scope.done();
510+
} finally {
511+
global.fetch = originalFetch;
512+
}
513+
});
514+
515+
it("includes the `returnTo` parameter", async () => {
516+
const { scope } = nockRefresh();
517+
518+
client = await createClient("client_123abc", {
519+
redirectUri: "https://example.com/",
520+
});
521+
522+
const originalFetch = global.fetch;
523+
const mockFetch = jest.fn().mockResolvedValue({
524+
ok: true,
525+
});
526+
global.fetch = mockFetch;
527+
528+
try {
529+
await client.signOut({
530+
returnTo: "https://example.com",
531+
navigate: false,
532+
});
533+
534+
// Location.assign should not be called
535+
expect(jest.mocked(location.assign)).not.toHaveBeenCalled();
536+
537+
expect(mockFetch).toHaveBeenCalledTimes(1);
538+
539+
const fetchUrl = new URL(mockFetch.mock.calls[0][0]);
540+
expect({
541+
fetchUrl,
542+
searchParams: Object.fromEntries(fetchUrl.searchParams.entries()),
543+
}).toEqual({
544+
fetchUrl: expect.objectContaining({
545+
origin: "https://api.workos.com",
546+
pathname: "/user_management/sessions/logout",
547+
}),
548+
searchParams: {
549+
session_id: "session_123abc",
550+
return_to: "https://example.com",
551+
},
552+
});
553+
554+
scope.done();
555+
} finally {
556+
global.fetch = originalFetch;
557+
}
558+
});
559+
});
560+
387561
describe("when tokens are persisted in local storage in development", () => {
388562
it("clears the tokens", async () => {
389563
localStorage.setItem(storageKeys.refreshToken, "refresh_token");
@@ -398,6 +572,48 @@ describe("create-client", () => {
398572
expect(localStorage.getItem(storageKeys.refreshToken)).toBeNull();
399573
scope.done();
400574
});
575+
576+
describe("when `returnTo` is provided", () => {
577+
it("clears the tokens", async () => {
578+
localStorage.setItem(storageKeys.refreshToken, "refresh_token");
579+
const { scope } = nockRefresh({ devMode: true });
580+
581+
client = await createClient("client_123abc", {
582+
redirectUri: "https://example.com/",
583+
devMode: true,
584+
});
585+
client.signOut({ returnTo: "https://example.com" });
586+
587+
expect(localStorage.getItem(storageKeys.refreshToken)).toBeNull();
588+
scope.done();
589+
});
590+
});
591+
592+
describe("when the `navigate` is set to false", () => {
593+
it("clears the tokens", async () => {
594+
localStorage.setItem(storageKeys.refreshToken, "refresh_token");
595+
const { scope } = nockRefresh({ devMode: true });
596+
597+
client = await createClient("client_123abc", {
598+
redirectUri: "https://example.com/",
599+
devMode: true,
600+
});
601+
602+
const originalFetch = global.fetch;
603+
const mockFetch = jest.fn().mockResolvedValue({
604+
ok: true,
605+
});
606+
global.fetch = mockFetch;
607+
608+
try {
609+
await client.signOut({ navigate: false });
610+
expect(localStorage.getItem(storageKeys.refreshToken)).toBeNull();
611+
scope.done();
612+
} finally {
613+
global.fetch = originalFetch;
614+
}
615+
});
616+
});
401617
});
402618
});
403619

src/create-client.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,32 @@ export class Client {
116116
}
117117
}
118118

119+
async getSignInUrl(opts: Omit<RedirectOptions, "type"> = {}) {
120+
const url = await this.#getAuthorizationUrl({ ...opts, type: "sign-in" });
121+
return url;
122+
}
123+
124+
async getSignUpUrl(opts: Omit<RedirectOptions, "type"> = {}) {
125+
const url = await this.#getAuthorizationUrl({ ...opts, type: "sign-up" });
126+
return url;
127+
}
128+
119129
async signIn(opts: Omit<RedirectOptions, "type"> = {}) {
120-
return this.#redirect({ ...opts, type: "sign-in" });
130+
const url = await this.#getAuthorizationUrl({ ...opts, type: "sign-in" });
131+
window.location.assign(url);
121132
}
122133

123134
async signUp(opts: Omit<RedirectOptions, "type"> = {}) {
124-
return this.#redirect({ ...opts, type: "sign-up" });
135+
const url = await this.#getAuthorizationUrl({ ...opts, type: "sign-up" });
136+
window.location.assign(url);
125137
}
126138

127-
signOut(options?: { returnTo: string }): void {
139+
signOut(options?: { returnTo?: string; navigate?: true }): void;
140+
signOut(options?: { returnTo?: string; navigate: false }): Promise<void>;
141+
signOut(
142+
options: { returnTo?: string; navigate?: boolean } = { navigate: true },
143+
): void | Promise<void> {
144+
const navigate = options.navigate ?? true;
128145
const accessToken = memoryStorage.getItem(storageKeys.accessToken);
129146
if (typeof accessToken !== "string") return;
130147
const { sid: sessionId } = getClaims(accessToken);
@@ -136,7 +153,21 @@ export class Client {
136153

137154
if (url) {
138155
removeSessionData({ devMode: this.#devMode });
139-
window.location.assign(url);
156+
157+
if (navigate) {
158+
window.location.assign(url);
159+
} else {
160+
return new Promise(async (resolve) => {
161+
fetch(url, {
162+
mode: "no-cors",
163+
credentials: "include",
164+
})
165+
.catch((error) => {
166+
console.warn("AuthKit: Failed to send logout request", error);
167+
})
168+
.finally(resolve);
169+
});
170+
}
140171
}
141172
}
142173

@@ -382,7 +413,7 @@ An authorization_code was supplied for a login which did not originate at the ap
382413
}
383414
}
384415

385-
async #redirect({
416+
async #getAuthorizationUrl({
386417
context,
387418
invitationToken,
388419
loginHint,
@@ -407,7 +438,7 @@ An authorization_code was supplied for a login which did not originate at the ap
407438
state: state ? JSON.stringify(state) : undefined,
408439
});
409440

410-
window.location.assign(url);
441+
return url;
411442
}
412443

413444
#getAccessToken() {

src/utils/is-redirect-callback.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ describe("isRedirectCallback", () => {
1616

1717
describe('when the given redirect URI ends with a "/"', () => {
1818
it("returns true when the current location otherwise matches without the slash", () => {
19+
const originalPath = window.location.pathname;
20+
window.history.replaceState({}, "", "/callback/");
21+
1922
const redirectUri = "https://example.com/callback";
2023
const searchParams = new URLSearchParams(Object.entries({ code: "123" }));
2124

2225
const result = isRedirectCallback(redirectUri, searchParams);
2326

27+
window.history.replaceState({}, "", originalPath);
28+
2429
expect(result).toBe(true);
2530
});
2631
});
@@ -46,4 +51,66 @@ describe("isRedirectCallback", () => {
4651
expect(result).toBe(false);
4752
});
4853
});
54+
55+
describe("when using redirect URIs in hybrid mobile app (e.g. Capacitor)", () => {
56+
/**
57+
* These tests ensure the function works for hybrid apps built with Capacitor.
58+
* For Android, the app uses http://localhost/callback as the current URL.
59+
* For iOS, the app uses capacitor://localhost/callback as the current URL.
60+
* See https://ionicframework.com/docs/troubleshooting/cors
61+
* The redirectUri remains https://example.com/callback.
62+
* In the mobile app context, and the redirectUri is deep linked towards the app.
63+
*/
64+
it("returns true when current location is http://localhost/callback and redirectUri is https://example.com/callback (Android)", () => {
65+
const originalLocation = window.location;
66+
const androidLocation = new URL("http://localhost/callback");
67+
68+
delete (window as any).location;
69+
Object.defineProperty(window, "location", {
70+
value: androidLocation,
71+
writable: true,
72+
configurable: true,
73+
});
74+
75+
const redirectUri = "https://example.com/callback";
76+
const searchParams = new URLSearchParams(Object.entries({ code: "123" }));
77+
78+
const result = isRedirectCallback(redirectUri, searchParams);
79+
80+
delete (window as any).location;
81+
Object.defineProperty(window, "location", {
82+
value: originalLocation,
83+
writable: true,
84+
configurable: true,
85+
});
86+
87+
expect(result).toBe(true);
88+
});
89+
90+
it("returns true when current location is capacitor://localhost/callback and redirectUri is https://example.com/callback (iOS)", () => {
91+
const originalLocation = window.location;
92+
const iOSLocation = new URL("capacitor://localhost/callback");
93+
94+
delete (window as any).location;
95+
Object.defineProperty(window, "location", {
96+
value: iOSLocation,
97+
writable: true,
98+
configurable: true,
99+
});
100+
101+
const redirectUri = "https://example.com/callback";
102+
const searchParams = new URLSearchParams(Object.entries({ code: "123" }));
103+
104+
const result = isRedirectCallback(redirectUri, searchParams);
105+
106+
delete (window as any).location;
107+
Object.defineProperty(window, "location", {
108+
value: originalLocation,
109+
writable: true,
110+
configurable: true,
111+
});
112+
113+
expect(result).toBe(true);
114+
});
115+
});
49116
});

0 commit comments

Comments
 (0)