Skip to content

Commit 05a6d87

Browse files
committed
Enhance OAuth and OTP handling with request context serialization
- Updated the OAuth authorization route to support a union response schema, allowing for both JSON and text responses with appropriate status codes. - Improved the OTP sign-in code route by incorporating request context serialization for new users, ensuring accurate tracking of sign-up details. - Refactored the verification code handler to deserialize stored sign-up request context, enhancing user experience during sign-in. - Added new utility functions for serializing and deserializing sign-up request context, improving data handling across authentication flows. - Enhanced tests to validate the new request context handling in OTP sign-in scenarios.
1 parent 39f4381 commit 05a6d87

12 files changed

Lines changed: 308 additions & 55 deletions

File tree

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSche
55
import { getProjectBranchFromClientId, getProvider } from "@/oauth";
66
import { globalPrismaClient } from "@/prisma-client";
77
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
8+
import type { SmartResponse } from "@/route-handlers/smart-response";
89
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
9-
import { urlSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
10+
import { urlSchema, yupArray, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
1011
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
1112
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1213
import { cookies } from "next/headers";
1314
import { redirect } from "next/navigation";
1415
import { generators } from "openid-client";
15-
import * as yup from "yup";
16+
import type { InferType, Schema } from "yup";
1617

1718
const outerOAuthFlowExpirationInMinutes = 10;
1819

@@ -52,15 +53,25 @@ export const GET = createSmartRouteHandler({
5253
response_type: yupString().defined(),
5354
}).noUnknown(/* Allow unknown query params such as ttclid, other stuff that's being injected by browsers */ false).defined(),
5455
}),
55-
response: yupObject({
56-
// The SDK uses stack_response_mode=json so it can intercept bot challenges before navigating.
57-
// The redirect path (default) is the legacy browser-direct flow.
58-
statusCode: yupNumber().oneOf([200]).defined(),
59-
bodyType: yupString().oneOf(["json"]).defined(),
60-
body: yupObject({
61-
location: yupString().defined(),
56+
response: yupUnion(
57+
yupObject({
58+
// The SDK uses stack_response_mode=json so it can intercept bot challenges before navigating.
59+
// The redirect path (default) is the legacy browser-direct flow.
60+
statusCode: yupNumber().oneOf([200]).defined(),
61+
bodyType: yupString().oneOf(["json"]).defined(),
62+
body: yupObject({
63+
location: yupString().defined(),
64+
}).defined(),
6265
}).defined(),
63-
}),
66+
yupObject({
67+
statusCode: yupNumber().oneOf([307]).defined(),
68+
headers: yupObject({
69+
location: yupArray(yupString().defined()).defined(),
70+
}).defined(),
71+
bodyType: yupString().oneOf(["text"]).defined(),
72+
body: yupString().defined(),
73+
}).defined(),
74+
) as unknown as Schema<SmartResponse>,
6475
async handler({ params, query }, fullReq) {
6576
const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(query.client_id), true);
6677
if (!tenancy) {
@@ -138,7 +149,7 @@ export const GET = createSmartRouteHandler({
138149
turnstileResult: turnstileAssessment.status,
139150
turnstileVisibleChallengeResult: turnstileAssessment.visibleChallengeResult,
140151
responseMode: query.stack_response_mode,
141-
} satisfies yup.InferType<typeof oauthCookieSchema>,
152+
} satisfies InferType<typeof oauthCookieSchema>,
142153
expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes),
143154
},
144155
});

apps/backend/src/app/api/latest/auth/otp/send-sign-in-code/route.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile";
2+
import { serializeStoredSignUpRequestContext } from "@/lib/sign-up-context";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
34
import { adaptSchema, clientOrHigherAuthTypeSchema, emailOtpSignInCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
45
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@@ -39,7 +40,7 @@ export const POST = createSmartRouteHandler({
3940

4041
await ensureUserForEmailAllowsOtp(tenancy, email);
4142

42-
const { turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(botChallenge, "send_magic_link_email", tenancy);
43+
const { requestContext, turnstileAssessment } = await getRequestContextAndBotChallengeAssessment(botChallenge, "send_magic_link_email", tenancy);
4344

4445
const { nonce } = await signInVerificationCodeHandler.sendCode(
4546
{
@@ -49,6 +50,7 @@ export const POST = createSmartRouteHandler({
4950
data: {
5051
turnstile_result: turnstileAssessment.status,
5152
turnstile_visible_challenge_result: turnstileAssessment.visibleChallengeResult,
53+
...serializeStoredSignUpRequestContext(requestContext),
5254
},
5355
},
5456
{ email }

apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-chann
33
import { sendEmailFromDefaultTemplate } from "@/lib/emails";
44
import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies";
55
import { createAuthTokens } from "@/lib/tokens";
6-
import { buildSignUpRuleOptions, reconstructTurnstileAssessment } from "@/lib/sign-up-context";
6+
import { buildSignUpRuleOptions, deserializeStoredSignUpRequestContext, reconstructTurnstileAssessment, storedSignUpRequestContextSchemaFields } from "@/lib/sign-up-context";
77
import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users";
88
import { getPrismaClientForTenancy } from "@/prisma-client";
99
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
@@ -78,6 +78,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
7878
data: yupObject({
7979
turnstile_result: yupString().oneOf(turnstileResultValues).defined(),
8080
turnstile_visible_challenge_result: yupString().oneOf(turnstileResultValues).optional(),
81+
...storedSignUpRequestContextSchemaFields,
8182
}),
8283
method: yupObject({
8384
email: emailSchema.defined(),
@@ -125,13 +126,11 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
125126
buildSignUpRuleOptions({
126127
authMethod: 'otp',
127128
oauthProvider: null,
128-
requestContext: null,
129+
requestContext: deserializeStoredSignUpRequestContext(data),
129130
turnstileAssessment: reconstructTurnstileAssessment(
130131
data.turnstile_result,
131132
data.turnstile_visible_challenge_result,
132133
),
133-
// Request context is not available in the verification code handler because the
134-
// sign-in code is verified in a separate request from where it was sent
135134
})
136135
);
137136
isNewUser = true;

apps/backend/src/lib/openapi.tsx

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,36 @@ function isMaybeRequestSchemaForAudience(requestDescribe: yup.SchemaObjectDescri
117117
return true;
118118
}
119119

120+
function getResponseDescriptions(responseSchema: yup.AnySchema): yup.SchemaObjectDescription[] {
121+
const schemaInfo = responseSchema.meta()?.stackSchemaInfo;
122+
const responseSchemas = schemaInfo?.type === "union" ? schemaInfo.items : [responseSchema];
123+
124+
return responseSchemas.map((schema) => {
125+
const responseDescribe = schema.describe();
126+
if (!isSchemaObjectDescription(responseDescribe)) {
127+
throw new Error('Response schema must be a yup.ObjectSchema');
128+
}
129+
return responseDescribe;
130+
});
131+
}
132+
133+
function mergeParsedResponses(parsedResponses: any[]) {
134+
const [firstResponse, ...remainingResponses] = parsedResponses;
135+
if (!firstResponse) {
136+
throw new StackAssertionError("Expected at least one parsed response");
137+
}
138+
139+
return remainingResponses.reduce((acc, response) => {
140+
return {
141+
...acc,
142+
responses: {
143+
...acc.responses,
144+
...response.responses,
145+
},
146+
};
147+
}, firstResponse);
148+
}
149+
120150

121151
function parseRouteHandler(options: {
122152
handler: SmartRouteHandler,
@@ -130,9 +160,8 @@ function parseRouteHandler(options: {
130160
if (overload.metadata?.hidden) continue;
131161

132162
const requestDescribe = overload.request.describe();
133-
const responseDescribe = overload.response.describe();
134163
if (!isSchemaObjectDescription(requestDescribe)) throw new Error('Request schema must be a yup.ObjectSchema');
135-
if (!isSchemaObjectDescription(responseDescribe)) throw new Error('Response schema must be a yup.ObjectSchema');
164+
const responseDescribes = getResponseDescriptions(overload.response);
136165

137166
// estimate whether this overload is the right one based on a heuristic
138167
if (!isMaybeRequestSchemaForAudience(requestDescribe, options.audience)) {
@@ -150,18 +179,20 @@ function parseRouteHandler(options: {
150179
`);
151180
}
152181

153-
result = parseOverload({
154-
metadata: overload.metadata,
155-
method: options.method,
156-
path: options.path,
157-
pathDesc: undefinedIfMixed(requestDescribe.fields.params),
158-
parameterDesc: undefinedIfMixed(requestDescribe.fields.query),
159-
headerDesc: undefinedIfMixed(requestDescribe.fields.headers),
160-
requestBodyDesc: undefinedIfMixed(requestDescribe.fields.body),
161-
responseDesc: undefinedIfMixed(responseDescribe.fields.body),
162-
responseTypeDesc: undefinedIfMixed(responseDescribe.fields.bodyType) ?? throwErr('Response type must be defined and not mixed', { options, bodyTypeField: responseDescribe.fields.bodyType }),
163-
statusCodeDesc: undefinedIfMixed(responseDescribe.fields.statusCode) ?? throwErr('Status code must be defined and not mixed', { options, statusCodeField: responseDescribe.fields.statusCode }),
164-
});
182+
result = mergeParsedResponses(responseDescribes.map((responseDescribe) => {
183+
return parseOverload({
184+
metadata: overload.metadata,
185+
method: options.method,
186+
path: options.path,
187+
pathDesc: undefinedIfMixed(requestDescribe.fields.params),
188+
parameterDesc: undefinedIfMixed(requestDescribe.fields.query),
189+
headerDesc: undefinedIfMixed(requestDescribe.fields.headers),
190+
requestBodyDesc: undefinedIfMixed(requestDescribe.fields.body),
191+
responseDesc: undefinedIfMixed(responseDescribe.fields.body),
192+
responseTypeDesc: undefinedIfMixed(responseDescribe.fields.bodyType) ?? throwErr('Response type must be defined and not mixed', { options, bodyTypeField: responseDescribe.fields.bodyType }),
193+
statusCodeDesc: undefinedIfMixed(responseDescribe.fields.statusCode) ?? throwErr('Status code must be defined and not mixed', { options, statusCodeField: responseDescribe.fields.statusCode }),
194+
});
195+
}));
165196
}
166197

167198
return result;

apps/backend/src/lib/sign-up-context.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,43 @@
1+
import { yupBoolean, yupString } from "@stackframe/stack-shared/dist/schema-fields";
12
import { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods";
23
import { BestEffortEndUserRequestContext } from "./end-users";
34
import { SignUpTurnstileAssessment } from "./turnstile";
45
import { SignUpRuleOptions } from "./users";
56

7+
export const storedSignUpRequestContextSchemaFields = {
8+
sign_up_ip_address: yupString().nullable().defined(),
9+
sign_up_ip_trusted: yupBoolean().nullable().defined(),
10+
sign_up_country_code: yupString().nullable().defined(),
11+
} as const;
12+
13+
export type StoredSignUpRequestContext = {
14+
sign_up_ip_address: string | null,
15+
sign_up_ip_trusted: boolean | null,
16+
sign_up_country_code: string | null,
17+
};
18+
19+
export function serializeStoredSignUpRequestContext(requestContext: BestEffortEndUserRequestContext): StoredSignUpRequestContext {
20+
return {
21+
sign_up_ip_address: requestContext.ipAddress,
22+
sign_up_ip_trusted: requestContext.ipTrusted,
23+
sign_up_country_code: requestContext.location?.countryCode ?? null,
24+
};
25+
}
26+
27+
export function deserializeStoredSignUpRequestContext(data: StoredSignUpRequestContext): BestEffortEndUserRequestContext | null {
28+
if (data.sign_up_ip_address == null && data.sign_up_ip_trusted == null && data.sign_up_country_code == null) {
29+
return null;
30+
}
31+
32+
return {
33+
ipAddress: data.sign_up_ip_address,
34+
ipTrusted: data.sign_up_ip_trusted,
35+
location: data.sign_up_country_code == null ? null : {
36+
countryCode: data.sign_up_country_code,
37+
},
38+
};
39+
}
40+
641
/**
742
* Builds a `SignUpRuleOptions` from a request context and auth details.
843
* Centralises the boilerplate that every auth route previously duplicated.
@@ -19,6 +54,7 @@ export function buildSignUpRuleOptions(params: {
1954
ipAddress: params.requestContext?.ipAddress ?? null,
2055
ipTrusted: params.requestContext?.ipTrusted ?? null,
2156
countryCode: params.requestContext?.location?.countryCode ?? null,
57+
requestContext: params.requestContext,
2258
turnstileAssessment: params.turnstileAssessment,
2359
};
2460
}

apps/backend/src/lib/turnstile.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export async function verifyTurnstileToken(params: {
113113
expectedAction: TurnstileAction,
114114
isAllowedHostname?: (hostname: string) => boolean,
115115
secretKey?: string,
116+
captureRejectedAsError?: boolean,
116117
}): Promise<SignUpTurnstileAssessment> {
117118
const token = params.token?.trim() ?? "";
118119
if (!token) {
@@ -134,12 +135,14 @@ export async function verifyTurnstileToken(params: {
134135
const data = result.data;
135136

136137
if (!data.success) {
137-
captureError("turnstile-siteverify-rejected", new StackAssertionError("Turnstile siteverify returned success=false", {
138-
errorCodes: data["error-codes"],
139-
expectedAction: params.expectedAction,
140-
receivedAction: data.action,
141-
hostname: data.hostname,
142-
}));
138+
if (params.captureRejectedAsError ?? true) {
139+
captureError("turnstile-siteverify-rejected", new StackAssertionError("Turnstile siteverify returned success=false", {
140+
errorCodes: data["error-codes"],
141+
expectedAction: params.expectedAction,
142+
receivedAction: data.action,
143+
hostname: data.hostname,
144+
}));
145+
}
143146
return { status: "invalid" };
144147
}
145148

@@ -165,7 +168,10 @@ export async function verifyTurnstileTokenWithOptionalVisibleChallenge(params: {
165168
phase?: "invisible" | "visible",
166169
secretKey?: string,
167170
}): Promise<SignUpTurnstileAssessment> {
168-
const assessment = await verifyTurnstileToken(params);
171+
const assessment = await verifyTurnstileToken({
172+
...params,
173+
captureRejectedAsError: params.phase !== "invisible",
174+
});
169175

170176
switch (params.phase) {
171177
case undefined: {
@@ -263,6 +269,21 @@ import.meta.vitest?.describe("verifyTurnstileToken(...)", () => {
263269
.resolves.toEqual({ status: "error" });
264270
});
265271

272+
test("can suppress captureError for expected siteverify rejections", async ({ expect }) => {
273+
const errorsModule = await import("@stackframe/stack-shared/dist/utils/errors");
274+
const captureErrorSpy = vi.spyOn(errorsModule, "captureError").mockImplementation(() => {});
275+
stubFetch({ success: false, action: "sign_up_with_credential" });
276+
277+
await expect(verifyTurnstileToken({
278+
...baseParams,
279+
token: "real-token",
280+
remoteIp: "127.0.0.1",
281+
captureRejectedAsError: false,
282+
})).resolves.toEqual({ status: "invalid" });
283+
284+
expect(captureErrorSpy).not.toHaveBeenCalled();
285+
});
286+
266287
const allowMyapp = (h: string) => h === "myapp.com" || matchHostnamePattern("*.myapp.com", h);
267288

268289
test("returns invalid when hostname does not match allowed hostnames", async ({ expect }) => {

apps/backend/src/lib/users.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { normalizeCountryCode, validCountryCodeSet } from "@stackframe/stack-sha
66
import { SignUpAuthMethod } from "@stackframe/stack-shared/dist/utils/auth-methods";
77
import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types";
88
import { createSignUpRuleContext } from "./cel-evaluator";
9-
import { getBestEffortEndUserRequestContext } from "./end-users";
9+
import { BestEffortEndUserRequestContext, getBestEffortEndUserRequestContext } from "./end-users";
1010
import { calculateSignUpRiskAssessment } from "./risk-scores";
1111
import { evaluateSignUpRules } from "./sign-up-rules";
1212
import { Tenancy } from "./tenancies";
@@ -23,6 +23,7 @@ export type SignUpRuleOptions = {
2323
ipAddress: string | null,
2424
ipTrusted: boolean | null,
2525
countryCode: string | null,
26+
requestContext?: BestEffortEndUserRequestContext | null,
2627
turnstileAssessment: SignUpTurnstileAssessment,
2728
};
2829

@@ -122,9 +123,11 @@ export async function createOrUpgradeAnonymousUserWithRules(
122123
): Promise<UsersCrud["Admin"]["Read"]> {
123124
const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? null;
124125
const primaryEmailVerified = createOrUpdate.primary_email_verified ?? currentUser?.primary_email_verified ?? false;
125-
const endUserRequestContext = signUpRuleOptions.ipAddress !== null && signUpRuleOptions.ipTrusted !== null && signUpRuleOptions.countryCode !== null
126-
? null
127-
: await getBestEffortEndUserRequestContext();
126+
const endUserRequestContext = signUpRuleOptions.requestContext !== undefined
127+
? signUpRuleOptions.requestContext
128+
: signUpRuleOptions.ipAddress !== null && signUpRuleOptions.ipTrusted !== null
129+
? null
130+
: await getBestEffortEndUserRequestContext();
128131
const requestIpAddress = signUpRuleOptions.ipAddress ?? endUserRequestContext?.ipAddress ?? null;
129132
const requestIpTrusted = signUpRuleOptions.ipTrusted ?? endUserRequestContext?.ipTrusted ?? null;
130133
// EndUserLocation.countryCode is string | undefined; coerce to string | null for downstream consumers

apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,44 @@ it("should sign in with both codes when requesting two sign in codes before usin
344344
`);
345345
});
346346

347+
it("should use the send-sign-in-code request context when creating a new OTP user", async ({ expect }) => {
348+
backendContext.set({
349+
ipData: {
350+
ipAddress: "127.0.0.70",
351+
country: "CA",
352+
city: "Toronto",
353+
region: "ON",
354+
latitude: 43.6532,
355+
longitude: -79.3832,
356+
tzIdentifier: "America/Toronto",
357+
},
358+
});
359+
360+
const { sendSignInCodeResponse } = await Auth.Otp.sendSignInCode();
361+
const signInCode = await Auth.Otp.getSignInCodeFromMailbox(sendSignInCodeResponse.body.nonce);
362+
363+
backendContext.set({
364+
ipData: {
365+
ipAddress: "127.0.0.71",
366+
country: "US",
367+
city: "New York",
368+
region: "NY",
369+
latitude: 40.7128,
370+
longitude: -74.006,
371+
tzIdentifier: "America/New_York",
372+
},
373+
});
374+
375+
const { userId } = await Auth.Otp.signInWithCode(signInCode);
376+
const userResponse = await niceBackendFetch(`/api/v1/users/${userId}`, {
377+
method: "GET",
378+
accessType: "server",
379+
});
380+
381+
expect(userResponse.status).toBe(200);
382+
expect(userResponse.body.country_code).toBe("CA");
383+
});
384+
347385
it.todo("should not sign in if e-mail's usedForAuth status has changed since sign-in code was sent");
348386

349387
it.todo("should not sign in if account's otpEnabled status has changed since sign-in code was sent");

packages/private

0 commit comments

Comments
 (0)