Skip to content

Commit a86788d

Browse files
committed
OAuth callback tests
1 parent 0c31d6a commit a86788d

File tree

17 files changed

+474
-79
lines changed

17 files changed

+474
-79
lines changed

apps/backend/src/app/api/v1/auth/oauth/authorize/[provider_id]/route.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getProvider } from "@/oauth";
55
import { prismaClient } from "@/prisma-client";
66
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
77
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
8-
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
99
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
1010
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1111
import { cookies } from "next/headers";
@@ -30,13 +30,13 @@ export const GET = createSmartRouteHandler({
3030
type: yupString().oneOf(["authenticate", "link"]).default("authenticate"),
3131
token: yupString().default(""),
3232
provider_scope: yupString().optional(),
33-
error_redirect_url: yupString().optional(),
33+
error_redirect_url: urlSchema.optional(),
3434
after_callback_redirect_url: yupString().optional(),
3535

3636
// oauth parameters
3737
client_id: yupString().required(),
3838
client_secret: yupString().required(),
39-
redirect_uri: yupString().required(),
39+
redirect_uri: urlSchema.required(),
4040
scope: yupString().required(),
4141
state: yupString().required(),
4242
grant_type: yupString().oneOf(["authorization_code"]).required(),
@@ -58,7 +58,7 @@ export const GET = createSmartRouteHandler({
5858
}
5959

6060
if (!await checkApiKeySet(query.client_id, { publishableClientKey: query.client_secret })) {
61-
throw new KnownErrors.ApiKeyNotFound();
61+
throw new KnownErrors.InvalidPublishableClientKey(query.client_id);
6262
}
6363

6464
const provider = project.config.oauth_providers.find((p) => p.id === params.provider_id);

apps/backend/src/oauth/providers/base.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Issuer, generators, CallbackParamsType, Client, TokenSet as OIDCTokenSe
22
import { OAuthUserInfo } from "../utils";
33
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
44
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
5+
import { KnownErrors } from "@stackframe/stack-shared";
56

67
export type TokenSet = {
78
accessToken: string,
@@ -111,8 +112,15 @@ export abstract class OAuthBaseProvider {
111112
};
112113
try {
113114
tokenSet = await this.oauthClient.oauthCallback(this.redirectUri, options.callbackParams, params);
114-
} catch (error) {
115+
} catch (error: any) {
116+
if (error?.error === "invalid_grant") {
117+
// while this is technically a "user" error, it would only be caused by a client that is not properly implemented
118+
// to catch the case where our own client is not properly implemented, we capture the error here
119+
captureError("inner-oauth-callback", error);
120+
throw new KnownErrors.InvalidAuthorizationCode();
121+
}
115122
throw new StackAssertionError(`Inner OAuth callback failed due to error: ${error}`, undefined, { cause: error });
123+
116124
}
117125

118126
tokenSet = processTokenSet(tokenSet);

apps/backend/src/polyfills.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as util from "util";
12
import { registerErrorSink } from "@stackframe/stack-shared/dist/utils/errors";
23
import * as Sentry from "@sentry/nextjs";
34

@@ -7,6 +8,10 @@ const sentryErrorSink = (location: string, error: unknown) => {
78

89
export function ensurePolyfilled() {
910
registerErrorSink(sentryErrorSink);
11+
// not all environments have default options for util.inspect
12+
if ("inspect" in util && "defaultOptions" in util.inspect) {
13+
util.inspect.defaultOptions.depth = 8;
14+
}
1015
}
1116

1217
ensurePolyfilled();

apps/backend/src/route-handlers/crud-handler.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,7 @@ export function createCrudHandlers<
196196
}),
197197
response: yupObject({
198198
statusCode: yupNumber().oneOf([crudOperation === "Create" ? 201 : 200]).required(),
199-
headers: yupObject({
200-
location: yupArray(yupString().required()).default([]),
201-
}),
199+
headers: yupObject({}),
202200
bodyType: yupString().oneOf([crudOperation === "Delete" ? "success" : "json"]).required(),
203201
body: accessSchemas.output,
204202
}),
@@ -214,9 +212,7 @@ export function createCrudHandlers<
214212

215213
return {
216214
statusCode: crudOperation === "Create" ? 201 : 200,
217-
headers: {
218-
location: crudOperation === "Create" ? [req.url] : [],
219-
},
215+
headers: {},
220216
bodyType: crudOperation === "Delete" ? "success" : "json",
221217
body: result,
222218
};

apps/dashboard/src/polyfills.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as util from "util";
12
import { registerErrorSink } from "@stackframe/stack-shared/dist/utils/errors";
23
import * as Sentry from "@sentry/nextjs";
34

@@ -7,6 +8,10 @@ const sentryErrorSink = (location: string, error: unknown) => {
78

89
export function ensurePolyfilled() {
910
registerErrorSink(sentryErrorSink);
11+
// not all environments have default options for util.inspect
12+
if ("inspect" in util && "defaultOptions" in util.inspect) {
13+
util.inspect.defaultOptions.depth = 8;
14+
}
1015
}
1116

1217
ensurePolyfilled();

apps/e2e/tests/backend/backend-helpers.ts

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/
22
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
33
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
44
import { expect } from "vitest";
5-
import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, createMailbox, niceFetch } from "../helpers";
5+
import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, createMailbox, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers";
66

77
type BackendContext = {
88
readonly projectKeys: ProjectKeys,
@@ -60,7 +60,7 @@ function expectSnakeCase(obj: unknown, path: string): void {
6060
}
6161
}
6262

63-
export async function niceBackendFetch(url: string, options?: Omit<NiceRequestInit, "body" | "headers"> & {
63+
export async function niceBackendFetch(url: string | URL, options?: Omit<NiceRequestInit, "body" | "headers"> & {
6464
accessType?: null | "client" | "server" | "admin",
6565
body?: unknown,
6666
headers?: Record<string, string | undefined>,
@@ -70,7 +70,10 @@ export async function niceBackendFetch(url: string, options?: Omit<NiceRequestIn
7070
expectSnakeCase(body, "req.body");
7171
}
7272
const { projectKeys, userAuth } = backendContext.value;
73-
const res = await niceFetch(new URL(url, STACK_BACKEND_BASE_URL), {
73+
const fullUrl = new URL(url, STACK_BACKEND_BASE_URL);
74+
if (fullUrl.origin !== new URL(STACK_BACKEND_BASE_URL).origin) throw new StackAssertionError(`Invalid niceBackendFetch origin: ${fullUrl.origin}`);
75+
if (fullUrl.protocol !== new URL(STACK_BACKEND_BASE_URL).protocol) throw new StackAssertionError(`Invalid niceBackendFetch protocol: ${fullUrl.protocol}`);
76+
const res = await niceFetch(fullUrl, {
7477
...otherOptions,
7578
...body !== undefined ? { body: JSON.stringify(body) } : {},
7679
headers: filterUndefined({
@@ -288,6 +291,153 @@ export namespace Auth {
288291
};
289292
}
290293
}
294+
295+
export namespace OAuth {
296+
export async function getAuthorizeQuery() {
297+
const projectKeys = backendContext.value.projectKeys;
298+
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
299+
300+
return {
301+
client_id: projectKeys.projectId,
302+
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
303+
redirect_uri: localRedirectUrl,
304+
scope: "legacy",
305+
response_type: "code",
306+
state: "this-is-some-state",
307+
grant_type: "authorization_code",
308+
code_challenge: "some-code-challenge",
309+
code_challenge_method: "plain",
310+
};
311+
}
312+
313+
export async function authorize(options?: { redirectUrl: string }) {
314+
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/facebook", {
315+
redirect: "manual",
316+
query: {
317+
...await Auth.OAuth.getAuthorizeQuery(),
318+
...filterUndefined({
319+
redirect_uri: options?.redirectUrl ?? undefined,
320+
}),
321+
},
322+
});
323+
expect(response.status).toBe(307);
324+
expect(response.headers.get("location")).toMatch(/^http:\/\/localhost:8107\/auth\?.*$/);
325+
expect(response.headers.get("set-cookie")).toMatch(/^stack-oauth-inner-[^;]+=[^;]+; Path=\/; Expires=[^;]+; Max-Age=\d+;( Secure;)? HttpOnly$/);
326+
return {
327+
authorizeResponse: response,
328+
};
329+
}
330+
331+
export async function getInnerCallbackUrl(options?: { authorizeResponse: NiceResponse }) {
332+
options ??= await Auth.OAuth.authorize();
333+
const providerPassword = generateSecureRandomString();
334+
const authLocation = new URL(options.authorizeResponse.headers.get("location")!);
335+
const redirectResponse1 = await niceFetch(authLocation, {
336+
redirect: "manual",
337+
});
338+
expect(redirectResponse1).toEqual({
339+
status: 303,
340+
headers: expect.any(Headers),
341+
body: expect.any(String),
342+
});
343+
const signInInteractionLocation = new URL(redirectResponse1.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse1 }), authLocation);
344+
const signInInteractionCookies = updateCookiesFromResponse("", redirectResponse1);
345+
const response1 = await niceFetch(signInInteractionLocation, {
346+
method: "POST",
347+
redirect: "manual",
348+
body: new URLSearchParams({
349+
prompt: "login",
350+
login: backendContext.value.mailbox.emailAddress,
351+
password: providerPassword,
352+
}),
353+
headers: {
354+
"content-type": "application/x-www-form-urlencoded",
355+
cookie: signInInteractionCookies,
356+
},
357+
});
358+
expect(response1).toEqual({
359+
status: 303,
360+
headers: expect.any(Headers),
361+
body: expect.any(ArrayBuffer),
362+
});
363+
const redirectResponse2 = await niceFetch(new URL(response1.headers.get("location") ?? throwErr("missing redirect location", { response1 }), signInInteractionLocation), {
364+
redirect: "manual",
365+
headers: {
366+
cookie: updateCookiesFromResponse(signInInteractionCookies, response1),
367+
},
368+
});
369+
expect(redirectResponse2).toEqual({
370+
status: 303,
371+
headers: expect.any(Headers),
372+
body: expect.any(String),
373+
});
374+
const authorizeInteractionLocation = new URL(redirectResponse2.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse2 }), authLocation);
375+
const authorizeInteractionCookies = updateCookiesFromResponse(signInInteractionCookies, redirectResponse2);
376+
const response2 = await niceFetch(authorizeInteractionLocation, {
377+
method: "POST",
378+
redirect: "manual",
379+
body: new URLSearchParams({
380+
prompt: "consent",
381+
}),
382+
headers: {
383+
"content-type": "application/x-www-form-urlencoded",
384+
cookie: authorizeInteractionCookies,
385+
},
386+
});
387+
expect(response2).toEqual({
388+
status: 303,
389+
headers: expect.any(Headers),
390+
body: expect.any(ArrayBuffer),
391+
});
392+
const redirectResponse3 = await niceFetch(new URL(response2.headers.get("location") ?? throwErr("missing redirect location", { response2 }), authLocation), {
393+
redirect: "manual",
394+
headers: {
395+
cookie: updateCookiesFromResponse(authorizeInteractionCookies, response2),
396+
},
397+
});
398+
expect(redirectResponse3).toEqual({
399+
status: 303,
400+
headers: expect.any(Headers),
401+
body: expect.any(String),
402+
});
403+
const innerCallbackUrl = new URL(redirectResponse3.headers.get("location") ?? throwErr("missing redirect location", { redirectResponse3 }));
404+
expect(innerCallbackUrl.origin).toBe("http://localhost:8102");
405+
expect(innerCallbackUrl.pathname).toBe("/api/v1/auth/oauth/callback/facebook");
406+
return {
407+
...options,
408+
innerCallbackUrl,
409+
};
410+
}
411+
412+
export async function getAuthorizationCode(options?: { innerCallbackUrl: URL, authorizeResponse: NiceResponse }) {
413+
options ??= await Auth.OAuth.getInnerCallbackUrl();
414+
const cookie = updateCookiesFromResponse("", options.authorizeResponse);
415+
const response = await niceBackendFetch(options.innerCallbackUrl.toString(), {
416+
redirect: "manual",
417+
headers: {
418+
cookie,
419+
},
420+
});
421+
expect(response).toEqual({
422+
status: 302,
423+
headers: expect.any(Headers),
424+
body: {},
425+
});
426+
const outerCallbackUrl = new URL(response.headers.get("location") ?? throwErr("missing redirect location", { response }));
427+
expect(outerCallbackUrl.origin).toBe(new URL(localRedirectUrl).origin);
428+
expect(outerCallbackUrl.pathname).toBe(new URL(localRedirectUrl).pathname);
429+
expect(Object.fromEntries(outerCallbackUrl.searchParams.entries())).toEqual({
430+
code: expect.any(String),
431+
state: "this-is-some-state",
432+
});
433+
434+
return {
435+
callbackResponse: response,
436+
outerCallbackUrl,
437+
authorizationCode: outerCallbackUrl.searchParams.get("code")!,
438+
};
439+
}
440+
}
291441
}
292442

293443
export namespace ContactChannels {

0 commit comments

Comments
 (0)