From c182cebec65577c137bcae0e43d128b167509fad Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Aug 2024 13:30:05 -0700 Subject: [PATCH] MFA for non-password apps --- .../oauth/authorize/[provider_id]/route.tsx | 8 +- .../oauth/callback/[provider_id]/route.tsx | 430 ++++++++---------- .../src/app/api/v1/auth/oauth/token/route.tsx | 27 +- apps/backend/src/oauth/model.tsx | 26 +- apps/e2e/tests/backend/backend-helpers.ts | 3 +- .../api/v1/auth/oauth/callback.test.ts | 93 ++-- .../endpoints/api/v1/auth/oauth/token.test.ts | 143 +++++- .../src/interface/clientInterface.ts | 4 + packages/stack-shared/src/schema-fields.ts | 20 +- packages/stack-shared/src/utils/errors.tsx | 21 +- .../src/components-page/account-settings.tsx | 5 - .../src/components/credential-sign-in.tsx | 3 - packages/stack/src/lib/auth.ts | 5 +- packages/stack/src/lib/stack-app.ts | 101 ++-- 14 files changed, 570 insertions(+), 319 deletions(-) diff --git a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider_id]/route.tsx index 6e651ea66..81c2cd3ef 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider_id]/route.tsx @@ -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, expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes), diff --git a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx index f97a975cb..c0f6500ca 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/callback/[provider_id]/route.tsx @@ -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); }, }); diff --git a/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx index 0f5515d79..2a2e71540 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/token/route.tsx @@ -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; } diff --git a/apps/backend/src/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index 61b374c87..ce88079e7 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -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 { assertScopeIsValid(scope); + return generateSecureRandomString(); } - async saveToken(token: Token, client: Client, user: User): Promise{ + async saveToken(token: Token, client: Client, user: User): Promise { 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, diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index a717d3dcd..d6a751c6b 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -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, }), }, }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts index 000bf09ab..1da33fde5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts @@ -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": ' at path '/'>, +