MFA for non-password apps

This commit is contained in:
Konstantin Wohlwend 2024-08-11 13:30:05 -07:00
parent 7555fa560d
commit c182cebec6
14 changed files with 570 additions and 319 deletions

View File

@ -30,7 +30,11 @@ export const GET = createSmartRouteHandler({
type: yupString().oneOf(["authenticate", "link"]).default("authenticate"),
token: yupString().default(""),
provider_scope: yupString().optional(),
error_redirect_url: urlSchema.optional(),
/**
* @deprecated
*/
error_redirect_url: urlSchema.optional().meta({ openapiField: { hidden: true } }),
error_redirect_uri: urlSchema.optional(),
after_callback_redirect_url: yupString().optional(),
// oauth parameters
@ -108,7 +112,7 @@ export const GET = createSmartRouteHandler({
type: query.type,
projectUserId: projectUserId,
providerScope: query.provider_scope,
errorRedirectUrl: query.error_redirect_url,
errorRedirectUrl: query.error_redirect_uri || query.error_redirect_url,
afterCallbackRedirectUrl: query.after_callback_redirect_url,
} satisfies yup.InferType<typeof oauthCookieSchema>,
expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes),

View File

@ -14,7 +14,6 @@ import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { oauthResponseToSmartResponse } from "../../oauth-helpers";
import { createMfaRequiredError } from "../../../mfa/sign-in/verification-code-handler";
const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"]["Read"], errorRedirectUrl?: string) => {
if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, project.config.domains, project.config.allow_localhost)) {
@ -43,7 +42,7 @@ export const GET = createSmartRouteHandler({
async handler({ params, query }, fullReq) {
const innerState = query.state ?? "";
const cookieInfo = cookies().get("stack-oauth-inner-" + innerState);
cookies().delete("stack-oauth-inner-" + query.state);
cookies().delete("stack-oauth-inner-" + innerState);
if (cookieInfo?.value !== 'true') {
throw new StatusError(StatusError.BadRequest, "OAuth cookie not found. This is likely because you refreshed the page during the OAuth sign in process. Please try signing in again");
@ -77,247 +76,222 @@ export const GET = createSmartRouteHandler({
} = outerInfo;
const project = await getProject(projectId);
if (!project) {
throw new StatusError(StatusError.BadRequest, "Invalid project ID");
throw new StackAssertionError("Project in outerInfo not found; has it been deleted?", { projectId });
}
if (outerInfoDB.expiresAt < new Date()) {
redirectOrThrowError(new KnownErrors.OuterOAuthTimeout(), project, errorRedirectUrl);
}
const provider = project.config.oauth_providers.find((p) => p.id === params.provider_id);
if (!provider || !provider.enabled) {
throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled();
}
const providerObj = await getProvider(provider);
const { userInfo, tokenSet } = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: innerState,
callbackParams: query,
});
if (type === "link") {
if (!projectUserId) {
throw new StackAssertionError("projectUserId not found in cookie when authorizing signed in user");
}
const user = await prismaClient.projectUser.findUnique({
where: {
projectId_projectUserId: {
projectId,
projectUserId,
},
},
include: {
projectUserOAuthAccounts: {
include: {
providerConfig: true,
}
}
}
});
if (!user) {
throw new StackAssertionError("User not found");
}
const account = user.projectUserOAuthAccounts.find((a) => a.providerConfig.id === provider.id);
if (account && account.providerAccountId !== userInfo.accountId) {
return redirectOrThrowError(new KnownErrors.UserAlreadyConnectedToAnotherOAuthConnection(), project, errorRedirectUrl);
}
}
const oauthRequest = new OAuthRequest({
headers: {},
body: {},
method: "GET",
query: {
client_id: outerInfo.projectId,
client_secret: outerInfo.publishableClientKey,
redirect_uri: outerInfo.redirectUri,
state: outerInfo.state,
scope: outerInfo.scope,
grant_type: outerInfo.grantType,
code_challenge: outerInfo.codeChallenge,
code_challenge_method: outerInfo.codeChallengeMethod,
response_type: outerInfo.responseType,
}
});
const storeTokens = async () => {
if (tokenSet.refreshToken) {
await prismaClient.oAuthToken.create({
data: {
projectId: outerInfo.projectId,
oAuthProviderConfigId: provider.id,
refreshToken: tokenSet.refreshToken,
providerAccountId: userInfo.accountId,
scopes: extractScopes(providerObj.scope + " " + providerScope),
}
});
}
await prismaClient.oAuthAccessToken.create({
data: {
projectId: outerInfo.projectId,
oAuthProviderConfigId: provider.id,
accessToken: tokenSet.accessToken,
providerAccountId: userInfo.accountId,
scopes: extractScopes(providerObj.scope + " " + providerScope),
expiresAt: tokenSet.accessTokenExpiredAt,
}
});
};
const oauthResponse = new OAuthResponse();
try {
await oauthServer.authorize(
oauthRequest,
oauthResponse,
{
authenticateHandler: {
handle: async () => {
const oldAccount = await prismaClient.projectUserOAuthAccount.findUnique({
where: {
projectId_oauthProviderConfigId_providerAccountId: {
projectId: outerInfo.projectId,
oauthProviderConfigId: provider.id,
providerAccountId: userInfo.accountId,
},
},
});
if (outerInfoDB.expiresAt < new Date()) {
throw new KnownErrors.OuterOAuthTimeout();
}
// ========================== link account with user ==========================
if (type === "link") {
if (!projectUserId) {
throw new StackAssertionError("projectUserId not found in cookie when authorizing signed in user");
}
const provider = project.config.oauth_providers.find((p) => p.id === params.provider_id);
if (!provider || !provider.enabled) {
throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled();
}
if (oldAccount) {
// ========================== account already connected ==========================
if (oldAccount.projectUserId !== projectUserId) {
throw new KnownErrors.OAuthConnectionAlreadyConnectedToAnotherUser();
}
await storeTokens();
} else {
// ========================== connect account with user ==========================
await prismaClient.projectUserOAuthAccount.create({
data: {
providerAccountId: userInfo.accountId,
email: userInfo.email,
providerConfig: {
connect: {
projectConfigId_id: {
projectConfigId: project.config.id,
id: provider.id,
},
},
},
projectUser: {
connect: {
projectId_projectUserId: {
projectId: outerInfo.projectId,
projectUserId: projectUserId,
},
},
},
},
});
}
const providerObj = await getProvider(provider);
const { userInfo, tokenSet } = await providerObj.getCallback({
codeVerifier: innerCodeVerifier,
state: innerState,
callbackParams: query,
});
await storeTokens();
return {
id: projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
} else {
if (type === "link") {
if (!projectUserId) {
throw new StackAssertionError("projectUserId not found in cookie when authorizing signed in user");
}
// ========================== sign in user ==========================
if (oldAccount) {
await storeTokens();
const projectUser = await prismaClient.projectUser.findUniqueOrThrow({
where: {
projectId_projectUserId: {
projectId: outerInfo.projectId,
projectUserId: oldAccount.projectUserId,
},
},
});
if (projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project,
userId: projectUser.projectUserId,
isNewUser: false,
});
}
return {
id: oldAccount.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
// ========================== sign up user ==========================
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const newAccount = await usersCrudHandlers.adminCreate({
project,
data: {
display_name: userInfo.displayName,
profile_image_url: userInfo.profileImageUrl || undefined,
primary_email: userInfo.email,
primary_email_verified: false, // TODO: check if email is verified with the provider
primary_email_auth_enabled: false,
oauth_providers: [{
id: provider.id,
account_id: userInfo.accountId,
email: userInfo.email,
}],
},
});
if (newAccount.requires_totp_mfa) {
throw await createMfaRequiredError({
project,
userId: newAccount.id,
isNewUser: true,
});
}
await storeTokens();
return {
id: newAccount.id,
newUser: true,
afterCallbackRedirectUrl,
};
const user = await prismaClient.projectUser.findUnique({
where: {
projectId_projectUserId: {
projectId,
projectUserId,
},
},
include: {
projectUserOAuthAccounts: {
include: {
providerConfig: true,
}
}
}
});
if (!user) {
throw new StackAssertionError("User not found");
}
);
const account = user.projectUserOAuthAccounts.find((a) => a.providerConfig.id === provider.id);
if (account && account.providerAccountId !== userInfo.accountId) {
throw new KnownErrors.UserAlreadyConnectedToAnotherOAuthConnection();
}
}
const oauthRequest = new OAuthRequest({
headers: {},
body: {},
method: "GET",
query: {
client_id: outerInfo.projectId,
client_secret: outerInfo.publishableClientKey,
redirect_uri: outerInfo.redirectUri,
state: outerInfo.state,
scope: outerInfo.scope,
grant_type: outerInfo.grantType,
code_challenge: outerInfo.codeChallenge,
code_challenge_method: outerInfo.codeChallengeMethod,
response_type: outerInfo.responseType,
}
});
const storeTokens = async () => {
if (tokenSet.refreshToken) {
await prismaClient.oAuthToken.create({
data: {
projectId: outerInfo.projectId,
oAuthProviderConfigId: provider.id,
refreshToken: tokenSet.refreshToken,
providerAccountId: userInfo.accountId,
scopes: extractScopes(providerObj.scope + " " + providerScope),
}
});
}
await prismaClient.oAuthAccessToken.create({
data: {
projectId: outerInfo.projectId,
oAuthProviderConfigId: provider.id,
accessToken: tokenSet.accessToken,
providerAccountId: userInfo.accountId,
scopes: extractScopes(providerObj.scope + " " + providerScope),
expiresAt: tokenSet.accessTokenExpiredAt,
}
});
};
const oauthResponse = new OAuthResponse();
try {
await oauthServer.authorize(
oauthRequest,
oauthResponse,
{
authenticateHandler: {
handle: async () => {
const oldAccount = await prismaClient.projectUserOAuthAccount.findUnique({
where: {
projectId_oauthProviderConfigId_providerAccountId: {
projectId: outerInfo.projectId,
oauthProviderConfigId: provider.id,
providerAccountId: userInfo.accountId,
},
},
});
// ========================== link account with user ==========================
if (type === "link") {
if (!projectUserId) {
throw new StackAssertionError("projectUserId not found in cookie when authorizing signed in user");
}
if (oldAccount) {
// ========================== account already connected ==========================
if (oldAccount.projectUserId !== projectUserId) {
throw new KnownErrors.OAuthConnectionAlreadyConnectedToAnotherUser();
}
await storeTokens();
} else {
// ========================== connect account with user ==========================
await prismaClient.projectUserOAuthAccount.create({
data: {
providerAccountId: userInfo.accountId,
email: userInfo.email,
providerConfig: {
connect: {
projectConfigId_id: {
projectConfigId: project.config.id,
id: provider.id,
},
},
},
projectUser: {
connect: {
projectId_projectUserId: {
projectId: outerInfo.projectId,
projectUserId: projectUserId,
},
},
},
},
});
}
await storeTokens();
return {
id: projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
} else {
// ========================== sign in user ==========================
if (oldAccount) {
await storeTokens();
return {
id: oldAccount.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
// ========================== sign up user ==========================
if (!project.config.sign_up_enabled) {
throw new KnownErrors.SignUpNotEnabled();
}
const newAccount = await usersCrudHandlers.adminCreate({
project,
data: {
display_name: userInfo.displayName,
profile_image_url: userInfo.profileImageUrl || undefined,
primary_email: userInfo.email,
primary_email_verified: false, // TODO: check if email is verified with the provider
primary_email_auth_enabled: false,
oauth_providers: [{
id: provider.id,
account_id: userInfo.accountId,
email: userInfo.email,
}],
},
});
await storeTokens();
return {
id: newAccount.id,
newUser: true,
afterCallbackRedirectUrl,
};
}
}
}
}
);
} catch (error) {
if (error instanceof InvalidClientError) {
if (error.message.includes("redirect_uri")) {
throw new KnownErrors.RedirectUrlNotWhitelisted();
}
}
throw error;
}
return oauthResponseToSmartResponse(oauthResponse);
} catch (error) {
if (error instanceof InvalidClientError) {
if (error.message.includes("redirect_uri")) {
throw new StatusError(
StatusError.BadRequest,
'Invalid redirect URL. Please ensure you set up domains and handlers correctly in Stack\'s dashboard.'
);
}
throw new StatusError(StatusError.BadRequest, error.message);
} else if (error instanceof KnownErrors.OAuthConnectionAlreadyConnectedToAnotherUser) {
return redirectOrThrowError(error, project, errorRedirectUrl);
if (error instanceof KnownError) {
redirectOrThrowError(error, project, errorRedirectUrl);
}
throw error;
}
return oauthResponseToSmartResponse(oauthResponse);
},
});

View File

@ -1,6 +1,6 @@
import { oauthServer } from "@/oauth";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { InvalidClientError, InvalidGrantError, Request as OAuthRequest, Response as OAuthResponse } from "@node-oauth/oauth2-server";
import { InvalidClientError, InvalidGrantError, InvalidRequestError, Request as OAuthRequest, Response as OAuthResponse, ServerError } from "@node-oauth/oauth2-server";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { oauthResponseToSmartResponse } from "../oauth-helpers";
@ -11,14 +11,18 @@ export const POST = createSmartRouteHandler({
description: "This endpoint is used to exchange an authorization code or refresh token for an access token.",
tags: ["Oauth"]
},
request: yupObject({}),
request: yupObject({
body: yupObject({
grant_type: yupString().oneOf(["authorization_code", "refresh_token"]).required(),
}).unknown().required(),
}).required(),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),
body: yupMixed().required(),
headers: yupMixed().required(),
}),
async handler({}, fullReq) {
async handler(req, fullReq) {
const oauthRequest = new OAuthRequest({
headers: {
...fullReq.headers,
@ -43,11 +47,26 @@ export const POST = createSmartRouteHandler({
);
} catch (e) {
if (e instanceof InvalidGrantError) {
throw new KnownErrors.RefreshTokenNotFoundOrExpired();
switch (req.body.grant_type) {
case "authorization_code": {
throw new KnownErrors.InvalidAuthorizationCode();
}
case "refresh_token": {
throw new KnownErrors.RefreshTokenNotFoundOrExpired();
}
}
}
if (e instanceof InvalidClientError) {
throw new KnownErrors.InvalidOAuthClientIdOrSecret();
}
if (e instanceof InvalidRequestError) {
if (e.message.includes("`redirect_uri` is invalid")) {
throw new KnownErrors.RedirectUrlNotWhitelisted();
}
}
if (e instanceof ServerError) {
throw (e as any).inner ?? e;
}
throw e;
}

View File

@ -1,3 +1,4 @@
import { fullProjectInclude, projectPrismaToCrud } from "@/lib/projects";
import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken, Token, User } from "@node-oauth/oauth2-server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
@ -8,6 +9,7 @@ import { checkApiKeySet } from "@/lib/api-keys";
import { getProject } from "@/lib/projects";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { KnownErrors } from "@stackframe/stack-shared";
import { createMfaRequiredError } from "@/app/api/v1/auth/mfa/sign-in/verification-code-handler";
const enabledScopes = ["legacy"];
@ -80,11 +82,33 @@ export class OAuthModel implements AuthorizationCodeModel {
async generateRefreshToken(client: Client, user: User, scope: string[]): Promise<string> {
assertScopeIsValid(scope);
return generateSecureRandomString();
}
async saveToken(token: Token, client: Client, user: User): Promise<Token | Falsey>{
async saveToken(token: Token, client: Client, user: User): Promise<Token | Falsey> {
if (token.refreshToken) {
const projectUser = await prismaClient.projectUser.findUniqueOrThrow({
where: {
projectId_projectUserId: {
projectId: client.id,
projectUserId: user.id,
},
},
include: {
project: {
include: fullProjectInclude,
},
},
});
if (projectUser.requiresTotpMfa) {
throw await createMfaRequiredError({
project: projectPrismaToCrud(projectUser.project),
userId: projectUser.projectUserId,
isNewUser: false,
});
}
await prismaClient.projectUserRefreshToken.create({
data: {
refreshToken: token.refreshToken,

View File

@ -315,13 +315,14 @@ export namespace Auth {
};
}
export async function authorize(options?: { redirectUrl: string }) {
export async function authorize(options?: { redirectUrl?: string, errorRedirectUrl?: string }) {
const response = await niceBackendFetch("/api/v1/auth/oauth/authorize/facebook", {
redirect: "manual",
query: {
...await Auth.OAuth.getAuthorizeQuery(),
...filterUndefined({
redirect_uri: options?.redirectUrl ?? undefined,
error_redirect_uri: options?.errorRedirectUrl ?? undefined,
}),
},
});

View File

@ -1,5 +1,5 @@
import { it, updateCookiesFromResponse } from "../../../../../../helpers";
import { it, localRedirectUrl, updateCookiesFromResponse } from "../../../../../../helpers";
import { ApiKey, Auth, Project, niceBackendFetch } from "../../../../../backend-helpers";
it("should return outer authorization code when inner callback url is valid", async ({ expect }) => {
@ -7,6 +7,13 @@ it("should return outer authorization code when inner callback url is valid", as
expect(response.authorizationCode).toBeTruthy();
});
it("should return outer authorization code when inner callback url is valid, even if invalid error redirect url is passed", async ({ expect }) => {
const authorize = await Auth.OAuth.authorize({ errorRedirectUrl: "http://error-redirect-url.stack-test.example.com" });
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl(authorize);
const response = await Auth.OAuth.getAuthorizationCode(getInnerCallbackUrlResponse);
expect(response.authorizationCode).toBeTruthy();
});
it("should fail when inner callback has invalid provider ID", async ({ expect }) => {
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const innerCallbackUrl = new URL(getInnerCallbackUrlResponse.innerCallbackUrl);
@ -105,6 +112,58 @@ it("should fail when inner callback has invalid authorization code", async ({ ex
`);
});
it("should redirect to error callback url when inner callback has invalid authorization code", async ({ expect }) => {
const authorize = await Auth.OAuth.authorize({ errorRedirectUrl: localRedirectUrl + "/callback-error" });
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl(authorize);
const innerCallbackUrl = new URL(getInnerCallbackUrlResponse.innerCallbackUrl);
innerCallbackUrl.searchParams.set("code", "invalid-authorization-code");
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
const response = await niceBackendFetch(innerCallbackUrl, {
redirect: "manual",
headers: {
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 307,
"headers": Headers {
"location": "http://stack-test.localhost/some-callback-url/callback-error?errorCode=INVALID_AUTHORIZATION_CODE&message=The%20given%20authorization%20code%20is%20invalid.&details=undefined",
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
<some fields may have been hidden>,
},
}
`);
});
it("should fail when inner callback has invalid authorization code and when an invalid error redirect url is passed", async ({ expect }) => {
const authorize = await Auth.OAuth.authorize({ errorRedirectUrl: "http://error-redirect-url.stack-test.example.com" });
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl(authorize);
const innerCallbackUrl = new URL(getInnerCallbackUrlResponse.innerCallbackUrl);
innerCallbackUrl.searchParams.set("code", "invalid-authorization-code");
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
const response = await niceBackendFetch(innerCallbackUrl, {
redirect: "manual",
headers: {
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "INVALID_AUTHORIZATION_CODE",
"error": "The given authorization code is invalid.",
},
"headers": Headers {
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
"x-stack-known-error": "INVALID_AUTHORIZATION_CODE",
<some fields may have been hidden>,
},
}
`);
});
it("should fail when inner callback has invalid state", async ({ expect }) => {
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const innerCallbackUrl = new URL(getInnerCallbackUrlResponse.innerCallbackUrl);
@ -138,42 +197,16 @@ it("should fail if an untrusted redirect URL is provided", async ({ expect }) =>
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": "Invalid redirect URL. Please ensure you set up domains and handlers correctly in Stack's dashboard.",
"headers": Headers {
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
<some fields may have been hidden>,
},
}
`);
});
it("should fail when MFA is required", async ({ expect }) => {
await Auth.OAuth.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {
redirect: "manual",
headers: {
cookie,
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
"code": "REDIRECT_URL_NOT_WHITELISTED",
"error": "Redirect URL not whitelisted.",
},
"headers": Headers {
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED",
<some fields may have been hidden>,
},
}

View File

@ -1,11 +1,13 @@
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { describe } from "vitest";
import { it } from "../../../../../../helpers";
import { Auth, niceBackendFetch } from "../../../../../backend-helpers";
import { it, localRedirectUrl } from "../../../../../../helpers";
import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
describe("with grant_type === 'authorization_code'", async () => {
it("should sign in a user when called as part of the OAuth flow", async ({ expect }) => {
const response = await Auth.OAuth.signIn();
expect(response.tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
@ -74,4 +76,141 @@ describe("with grant_type === 'authorization_code'", async () => {
}
`);
});
it("should fail when called with an invalid code_challenge", async ({ expect }) => {
const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode();
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: {
client_id: projectKeys.projectId,
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
code: getAuthorizationCodeResult.authorizationCode,
redirect_uri: localRedirectUrl,
code_verifier: "invalid-code-challenge",
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "INVALID_AUTHORIZATION_CODE",
"error": "The given authorization code is invalid.",
},
"headers": Headers {
"x-stack-known-error": "INVALID_AUTHORIZATION_CODE",
<some fields may have been hidden>,
},
}
`);
});
it("should fail when called with an invalid redirect_uri", async ({ expect }) => {
const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode();
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: {
client_id: projectKeys.projectId,
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
code: getAuthorizationCodeResult.authorizationCode,
redirect_uri: "http://invalid-redirect-uri.example.com",
code_verifier: "some-code-challenge",
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "REDIRECT_URL_NOT_WHITELISTED",
"error": "Redirect URL not whitelisted.",
},
"headers": Headers {
"x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED",
<some fields may have been hidden>,
},
}
`);
});
it("should fail when called with an invalid code", async ({ expect }) => {
const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode();
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: {
client_id: projectKeys.projectId,
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
code: "invalid-code",
redirect_uri: localRedirectUrl,
code_verifier: "some-code-challenge",
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "INVALID_AUTHORIZATION_CODE",
"error": "The given authorization code is invalid.",
},
"headers": Headers {
"x-stack-known-error": "INVALID_AUTHORIZATION_CODE",
<some fields may have been hidden>,
},
}
`);
});
it("should fail when MFA is required", async ({ expect }) => {
await Auth.OAuth.signIn();
await Auth.Mfa.setupTotpMfa();
await Auth.signOut();
const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode();
const projectKeys = backendContext.value.projectKeys;
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
method: "POST",
accessType: "client",
body: {
client_id: projectKeys.projectId,
client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"),
code: getAuthorizationCodeResult.authorizationCode,
redirect_uri: localRedirectUrl,
code_verifier: "some-code-challenge",
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
"details": { "attempt_code": <stripped field 'attempt_code'> },
"error": "Multi-factor authentication is required for this user.",
},
"headers": Headers {
"x-stack-known-error": "MULTI_FACTOR_AUTHENTICATION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
});

View File

@ -616,6 +616,7 @@ export class StackClientInterface {
return {
accessToken: result.access_token,
refreshToken: result.refresh_token,
newUser: result.is_new_user,
};
}
@ -802,6 +803,9 @@ export class StackClientInterface {
const result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
if ("code" in result && result.code === "MULTI_FACTOR_AUTHENTICATION_REQUIRED") {
throw new KnownErrors.MultiFactorAuthenticationRequired((result as any).details.attempt_code);
}
// TODO Handle OAuth 2.0 response body error
throw new StackAssertionError("Outer OAuth error during authorization code response", { result });
}

View File

@ -49,15 +49,17 @@ export function yupObject<A extends yup.Maybe<yup.AnyObject>, B extends yup.Obje
({ path }) => `${path} contains unknown properties`,
(value: any, context) => {
if (context.options.context?.noUnknownPathPrefixes?.some((prefix: string) => context.path.startsWith(prefix))) {
const availableKeys = new Set(Object.keys(context.schema.fields));
const unknownKeys = Object.keys(value ?? {}).filter(key => !availableKeys.has(key));
if (unknownKeys.length > 0) {
// TODO "did you mean XYZ"
return context.createError({
message: `${context.path} contains unknown properties: ${unknownKeys.join(', ')}`,
path: context.path,
params: { unknownKeys },
});
if (context.schema.spec.noUnknown !== false) {
const availableKeys = new Set(Object.keys(context.schema.fields));
const unknownKeys = Object.keys(value ?? {}).filter(key => !availableKeys.has(key));
if (unknownKeys.length > 0) {
// TODO "did you mean XYZ"
return context.createError({
message: `${context.path} contains unknown properties: ${unknownKeys.join(', ')}`,
path: context.path,
params: { unknownKeys },
});
}
}
}
return true;

View File

@ -17,16 +17,27 @@ export function throwErr(...args: any[]): never {
}
export class StackAssertionError extends Error {
export class StackAssertionError extends Error implements ErrorWithCustomCapture {
constructor(message: string, public readonly extraData?: Record<string, any>, options?: ErrorOptions) {
const disclaimer = `\n\nThis is likely an error in Stack. Please make sure you are running the newest version and report it.`;
super(`${message}${message.endsWith(disclaimer) ? "" : disclaimer}`, options);
}
customCaptureExtraArgs = [
{
...this.extraData,
...this.cause ? { cause: this.cause } : {},
},
];
}
StackAssertionError.prototype.name = "StackAssertionError";
const errorSinks = new Set<(location: string, error: unknown) => void>();
export type ErrorWithCustomCapture = {
customCaptureExtraArgs: any[],
};
const errorSinks = new Set<(location: string, error: unknown, ...extraArgs: unknown[]) => void>();
export function registerErrorSink(sink: (location: string, error: unknown) => void): void {
if (errorSinks.has(sink)) {
return;
@ -43,7 +54,11 @@ registerErrorSink((location, error, ...extraArgs) => {
export function captureError(location: string, error: unknown): void {
for (const sink of errorSinks) {
sink(location, error);
sink(
location,
error,
...error && (typeof error === 'object' || typeof error === 'function') && "customCaptureExtraArgs" in error && Array.isArray(error.customCaptureExtraArgs) ? (error.customCaptureExtraArgs as any[]) : [],
);
}
}

View File

@ -220,11 +220,6 @@ function PasswordSection() {
function MfaSection() {
const project = useStackApp().useProject();
if (project.config.oauthProviders.length !== 0 || project.config.magicLinkEnabled) {
// TODO next-release support MFA for OAuth and magic link
return null;
}
const user = useUser({ or: "throw" });
const [generatedSecret, setGeneratedSecret] = useState<Uint8Array | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);

View File

@ -31,9 +31,6 @@ export function CredentialSignIn() {
const error = await app.signInWithCredential({
email,
password,
// TODO next-release remove
...{ __experimental_mfa: true },
});
setError('email', { type: 'manual', message: error?.message });
} finally {

View File

@ -1,4 +1,4 @@
import { StackClientInterface } from "@stackframe/stack-shared";
import { KnownError, StackClientInterface } from "@stackframe/stack-shared";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises";
@ -117,6 +117,9 @@ export async function callOAuthCallback(
state,
});
} catch (e) {
if (e instanceof KnownError) {
throw e;
}
throw new StackAssertionError("Error signing in during OAuth callback. Please try again.", { cause: e });
}
}

View File

@ -648,6 +648,9 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
protected async _signInToAccountWithTokens(tokens: { accessToken: string | null, refreshToken: string }) {
if (!("accessToken" in tokens) || !("refreshToken" in tokens)) {
throw new StackAssertionError("Invalid tokens object; can't sign in with this", { tokens });
}
const tokenStore = this._getOrCreateTokenStore();
tokenStore.set(tokens);
}
@ -1105,39 +1108,54 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
);
}
/**
* @deprecated
* TODO remove
*/
protected async _experimentalMfa(error: KnownErrors['MultiFactorAuthenticationRequired'], session: InternalSession) {
const otp = prompt('Please enter the six-digit TOTP code from your authenticator app.');
if (!otp) {
throw new KnownErrors.InvalidTotpCode();
}
return await this._interface.totpMfa(
(error.details as any)?.attempt_code ?? throwErr("attempt code missing"),
otp,
session
);
}
/**
* @deprecated
* TODO remove
*/
protected async _catchMfaRequiredError<T>(callback: () => Promise<T>) {
try {
return await callback();
} catch (e) {
if (e instanceof KnownErrors.MultiFactorAuthenticationRequired) {
return await this._experimentalMfa(e, this._getSession());
}
throw e;
}
}
async signInWithCredential(options: {
email: string,
password: string,
// TODO next-release remove
__experimental_mfa?: boolean,
}): Promise<KnownErrors["EmailPasswordMismatch"] | void> {
}): Promise<KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"] | void> {
this._ensurePersistentTokenStore();
const session = this._getSession();
let result;
try {
result = await this._interface.signInWithCredential(options.email, options.password, session);
result = await this._catchMfaRequiredError(async () => {
return await this._interface.signInWithCredential(options.email, options.password, session);
});
} catch (e) {
// TODO next-release remove
if (options.__experimental_mfa && e instanceof KnownErrors.MultiFactorAuthenticationRequired) {
const otp = prompt('Please enter the six-digit TOTP code from your authenticator app.');
try {
if (!otp) {
throw new KnownErrors.InvalidTotpCode();
}
result = await this._interface.totpMfa(
(e.details as any)?.attempt_code ?? throwErr("attempt code missing"),
otp,
session
);
} catch (e) {
if (e instanceof KnownErrors.InvalidTotpCode) {
return e as any; // hack
}
throw e;
}
} else {
throw e;
if (e instanceof KnownErrors.InvalidTotpCode) {
return e;
}
throw e;
}
if (!(result instanceof KnownError)) {
await this._signInToAccountWithTokens(result);
@ -1166,9 +1184,19 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return result;
}
async signInWithMagicLink(code: string): Promise<KnownErrors["VerificationCodeError"] | void> {
async signInWithMagicLink(code: string): Promise<KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"] | void> {
this._ensurePersistentTokenStore();
const result = await this._interface.signInWithMagicLink(code);
let result;
try {
result = await this._catchMfaRequiredError(async () => {
return await this._interface.signInWithMagicLink(code);
});
} catch (e) {
if (e instanceof KnownErrors.InvalidTotpCode) {
return e;
}
throw e;
}
if (result instanceof KnownError) {
return result;
}
@ -1182,10 +1210,23 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async callOAuthCallback() {
this._ensurePersistentTokenStore();
const result = await callOAuthCallback(this._interface, this.urls.oauthCallback);
let result;
try {
result = await this._catchMfaRequiredError(async () => {
return await callOAuthCallback(this._interface, this.urls.oauthCallback);
});
} catch (e) {
if (e instanceof KnownErrors.InvalidTotpCode) {
alert("Invalid TOTP code. Please try signing in again.");
}
throw e;
}
if (result) {
console.log("OAuth callback result", result);
await this._signInToAccountWithTokens(result);
if (result.afterCallbackRedirectUrl) {
// TODO fix afterCallbackRedirectUrl for MFA (currently not passed because /mfa/sign-in doesn't return it)
// or just get rid of afterCallbackRedirectUrl entirely tbh
if ("afterCallbackRedirectUrl" in result && result.afterCallbackRedirectUrl) {
await _redirectTo(result.afterCallbackRedirectUrl, { replace: true });
return true;
} else if (result.newUser) {
@ -2630,7 +2671,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
readonly urls: Readonly<HandlerUrls>,
signInWithOAuth(provider: string): Promise<void>,
signInWithCredential(options: { email: string, password: string }): Promise<KnownErrors["EmailPasswordMismatch"] | void>,
signInWithCredential(options: { email: string, password: string }): Promise<KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"] | void>,
signUpWithCredential(options: { email: string, password: string }): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | void>,
callOAuthCallback(): Promise<boolean>,
sendForgotPasswordEmail(email: string): Promise<KnownErrors["UserNotFound"] | void>,
@ -2641,7 +2682,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>>,
verifyEmail(code: string): Promise<KnownErrors["VerificationCodeError"] | void>,
signInWithMagicLink(code: string): Promise<KnownErrors["VerificationCodeError"] | void>,
signInWithMagicLink(code: string): Promise<KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"] | void>,
redirectToOAuthCallback(): Promise<void>,
useUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentUser<ProjectId>,