mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
MFA for non-password apps
This commit is contained in:
parent
7555fa560d
commit
c182cebec6
@ -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),
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[]) : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user