diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 399b58e572..510bb1672b 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -1,11 +1,23 @@ # Changelog -## 0.14.0 (2024-04-22) +## 0.14.0 (TBD) ### 🎉 New Features - Simplified Auth User API: Introduced a simpler API for accessing user auth fields (for example `username`, `email`, `isEmailVerified`) directly on the `user` object, eliminating the need for helper functions. - Improved API for calling Operations (Queries and Actions) directly. +- Auth Hooks: you can now hook into the auth process with `onBeforeSignup`, `onAfterSignup` hooks. You can also modify the OAuth redirect URL with `onBeforeOAuthRedirect` hook. + + ```wasp + app myApp { + ... + auth: { + onBeforeSignup: import { onBeforeSignup } from "...", + onAfterSignup: import { onAfterSignup } from "...", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "...", + }, + } + ``` ### ⚠️ Breaking Changes & Migration Guide diff --git a/waspc/data/Generator/templates/sdk/wasp/server/auth/hooks.ts b/waspc/data/Generator/templates/sdk/wasp/server/auth/hooks.ts new file mode 100644 index 0000000000..35aeca279e --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/server/auth/hooks.ts @@ -0,0 +1,87 @@ +import type { Request as ExpressRequest } from 'express' +import type { ProviderId, createUser } from '../../auth/utils.js' +import { prisma } from '../index.js' +import { Expand } from '../../universal/types.js' + +// PUBLIC API +export type OnBeforeSignupHook = ( + params: Expand, +) => void | Promise + +// PUBLIC API +export type OnAfterSignupHook = ( + params: Expand, +) => void | Promise + +// PUBLIC API +/** + * @returns Object with a URL that the OAuth flow should redirect to. + */ +export type OnBeforeOAuthRedirectHook = ( + params: Expand, +) => { url: URL } | Promise<{ url: URL }> + +// PRIVATE API (used in the SDK and the server) +export type InternalAuthHookParams = { + /** + * Prisma instance that can be used to interact with the database. + */ + prisma: typeof prisma +} + +// NOTE: We should be exporting types that can be reached by users via other +// exported types (e.g. using the Parameters Typescript helper). +// However, we are not exporting this type to keep the API surface smaller. +// This type is only used internally by the SDK. Exporting it might confuse +// users since the name is too similar to the exported function type. +// Same goes for all other *Params types in this file. +type OnBeforeSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams + +type OnAfterSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId + /** + * User object that was created during the signup process. + */ + user: Awaited> + oauth?: { + /** + * Access token that was received during the OAuth flow. + */ + accessToken: string + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string + }, + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams + +type OnBeforeOAuthRedirectHookParams = { + /** + * URL that the OAuth flow should redirect to. + */ + url: URL + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams diff --git a/waspc/data/Generator/templates/sdk/wasp/server/auth/index.ts b/waspc/data/Generator/templates/sdk/wasp/server/auth/index.ts index 1589754e1e..8f746f2e8b 100644 --- a/waspc/data/Generator/templates/sdk/wasp/server/auth/index.ts +++ b/waspc/data/Generator/templates/sdk/wasp/server/auth/index.ts @@ -23,6 +23,13 @@ export { ensureTokenIsPresent, } from '../../auth/validation.js' +export type { + OnBeforeSignupHook, + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + InternalAuthHookParams, +} from './hooks.js' + {=# isEmailAuthEnabled =} export * from './email/index.js' {=/ isEmailAuthEnabled =} diff --git a/waspc/data/Generator/templates/sdk/wasp/server/auth/user.ts b/waspc/data/Generator/templates/sdk/wasp/server/auth/user.ts index 9be0fab36f..7427bc92e8 100644 --- a/waspc/data/Generator/templates/sdk/wasp/server/auth/user.ts +++ b/waspc/data/Generator/templates/sdk/wasp/server/auth/user.ts @@ -17,7 +17,7 @@ export type AuthUser = AuthUserData & { } // PRIVATE API -/** +/* * Ideally, we'd do something like this: * ``` * export type AuthUserData = ReturnType diff --git a/waspc/data/Generator/templates/server/src/auth/hooks.ts b/waspc/data/Generator/templates/server/src/auth/hooks.ts new file mode 100644 index 0000000000..d9b29a2eb5 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/hooks.ts @@ -0,0 +1,80 @@ +{{={= =}=}} +import { prisma } from 'wasp/server' +import type { + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + OnBeforeSignupHook, + InternalAuthHookParams, +} from 'wasp/server/auth' +{=# onBeforeSignupHook.isDefined =} +{=& onBeforeSignupHook.importStatement =} +{=/ onBeforeSignupHook.isDefined =} +{=# onAfterSignupHook.isDefined =} +{=& onAfterSignupHook.importStatement =} +{=/ onAfterSignupHook.isDefined =} +{=# onBeforeOAuthRedirectHook.isDefined =} +{=& onBeforeOAuthRedirectHook.importStatement =} +{=/ onBeforeOAuthRedirectHook.isDefined =} + +/* + These are "internal hook functions" based on the user defined hook functions. + + In the server code (e.g. email signup) we import these functions and call them. + + We want to pass extra params to the user defined hook functions, but we don't want to + pass them when we call them in the server code. +*/ + +{=# onBeforeSignupHook.isDefined =} +export const onBeforeSignupHook: InternalFunctionForHook = (params) => + {= onBeforeSignupHook.importIdentifier =}({ + prisma, + ...params, + }) +{=/ onBeforeSignupHook.isDefined =} +{=^ onBeforeSignupHook.isDefined =} +/** + * This is a no-op function since the user didn't define the onBeforeSignup hook. + */ +export const onBeforeSignupHook: InternalFunctionForHook = async (_params) => {} +{=/ onBeforeSignupHook.isDefined =} + +{=# onAfterSignupHook.isDefined =} +export const onAfterSignupHook: InternalFunctionForHook = (params) => + {= onAfterSignupHook.importIdentifier =}({ + prisma, + ...params, + }) +{=/ onAfterSignupHook.isDefined =} +{=^ onAfterSignupHook.isDefined =} +/** + * This is a no-op function since the user didn't define the onAfterSignup hook. + */ +export const onAfterSignupHook: InternalFunctionForHook = async (_params) => {} +{=/ onAfterSignupHook.isDefined =} + +{=# onBeforeOAuthRedirectHook.isDefined =} +export const onBeforeOAuthRedirectHook: InternalFunctionForHook = (params) => + {= onBeforeOAuthRedirectHook.importIdentifier =}({ + prisma, + ...params, + }) +{=/ onBeforeOAuthRedirectHook.isDefined =} +{=^ onBeforeOAuthRedirectHook.isDefined =} +/** + * This is an identity function since the user didn't define the onBeforeOAuthRedirect hook. + */ +export const onBeforeOAuthRedirectHook: InternalFunctionForHook = async (params) => params +{=/ onBeforeOAuthRedirectHook.isDefined =} + +/* + We pass extra params to the user defined hook functions, but we don't want to + pass the extra params (e.g. 'prisma') when we call the hooks in the server code. + So, we need to remove the extra params from the params object which is used to define the + internal hook functions. +*/ +type InternalFunctionForHook unknown | Promise> = Fn extends ( + params: infer P, +) => infer R + ? (args: Omit) => R + : never diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/github.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/github.ts index 3bfbd63877..12723302f9 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/github.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/github.ts @@ -77,13 +77,11 @@ const _waspConfig: ProviderConfig = { return createOAuthProviderRouter({ provider, - stateTypes: ['state'], + oAuthType: 'OAuth2', userSignupFields: _waspUserSignupFields, getAuthorizationUrl: ({ state }) => github.createAuthorizationURL(state, config), - getProviderInfo: async ({ code }) => { - const { accessToken } = await github.validateAuthorizationCode(code); - return getGithubProfile(accessToken); - }, + getProviderTokens: ({ code }) => github.validateAuthorizationCode(code), + getProviderInfo: ({ accessToken }) => getGithubProfile(accessToken), }); }, } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/google.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/google.ts index caf38a87ab..93ba70bf2e 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/google.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/google.ts @@ -66,13 +66,11 @@ const _waspConfig: ProviderConfig = { return createOAuthProviderRouter({ provider, - stateTypes: ['state', 'codeVerifier'], + oAuthType: 'OAuth2WithPKCE', userSignupFields: _waspUserSignupFields, getAuthorizationUrl: ({ state, codeVerifier }) => google.createAuthorizationURL(state, codeVerifier, config), - getProviderInfo: async ({ code, codeVerifier }) => { - const { accessToken } = await google.validateAuthorizationCode(code, codeVerifier); - return getGoogleProfile(accessToken); - }, + getProviderTokens: ({ code, codeVerifier }) => google.validateAuthorizationCode(code, codeVerifier), + getProviderInfo: ({ accessToken }) => getGoogleProfile(accessToken), }); }, } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/keycloak.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/keycloak.ts index 52c7a51118..95d75c656f 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/keycloak.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/keycloak.ts @@ -68,13 +68,11 @@ const _waspConfig: ProviderConfig = { return createOAuthProviderRouter({ provider, - stateTypes: ['state', 'codeVerifier'], + oAuthType: 'OAuth2WithPKCE', userSignupFields: _waspUserSignupFields, getAuthorizationUrl: ({ state, codeVerifier }) => keycloak.createAuthorizationURL(state, codeVerifier, config), - getProviderInfo: async ({ code, codeVerifier }) => { - const { accessToken } = await keycloak.validateAuthorizationCode(code, codeVerifier); - return getKeycloakProfile(accessToken); - }, + getProviderTokens: ({ code, codeVerifier }) => keycloak.validateAuthorizationCode(code, codeVerifier), + getProviderInfo: ({ accessToken }) => getKeycloakProfile(accessToken), }); }, } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index a22a7efb66..ddba171153 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -1,150 +1,163 @@ -import { Request, Response } from 'express'; -import { EmailFromField } from "wasp/server/email/core/types"; +import { Request, Response } from 'express' +import { EmailFromField } from 'wasp/server/email/core/types' import { - createUser, - createProviderId, - findAuthIdentity, - deleteUserByAuthId, - doFakeWork, - deserializeAndSanitizeProviderData, - sanitizeAndSerializeProviderData, - rethrowPossibleAuthError, -} from 'wasp/auth/utils'; + createUser, + createProviderId, + findAuthIdentity, + deleteUserByAuthId, + doFakeWork, + deserializeAndSanitizeProviderData, + sanitizeAndSerializeProviderData, + rethrowPossibleAuthError, +} from 'wasp/auth/utils' import { - createEmailVerificationLink, - sendEmailVerificationEmail, - isEmailResendAllowed, -} from "wasp/server/auth/email/utils"; -import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from 'wasp/auth/validation'; -import { GetVerificationEmailContentFn } from 'wasp/server/auth/email'; + createEmailVerificationLink, + sendEmailVerificationEmail, + isEmailResendAllowed, +} from 'wasp/server/auth/email/utils' +import { + ensureValidEmail, + ensureValidPassword, + ensurePasswordIsPresent, +} from 'wasp/auth/validation' +import { GetVerificationEmailContentFn } from 'wasp/server/auth/email' import { validateAndGetUserFields } from 'wasp/auth/utils' -import { HttpError } from 'wasp/server'; -import { type UserSignupFields } from 'wasp/auth/providers/types'; +import { HttpError } from 'wasp/server' +import { type UserSignupFields } from 'wasp/auth/providers/types' +import { onBeforeSignupHook, onAfterSignupHook } from '../../hooks.js' export function getSignupRoute({ - userSignupFields, - fromField, - clientRoute, - getVerificationEmailContent, - isEmailAutoVerified, + userSignupFields, + fromField, + clientRoute, + getVerificationEmailContent, + isEmailAutoVerified, }: { - userSignupFields?: UserSignupFields; - fromField: EmailFromField; - clientRoute: string; - getVerificationEmailContent: GetVerificationEmailContentFn; - isEmailAutoVerified: boolean; + userSignupFields?: UserSignupFields + fromField: EmailFromField + clientRoute: string + getVerificationEmailContent: GetVerificationEmailContentFn + isEmailAutoVerified: boolean }) { - return async function signup( - req: Request<{ email: string; password: string; }>, - res: Response, - ): Promise> { - const fields = req.body; - ensureValidArgs(fields); - - const providerId = createProviderId("email", fields.email); - const existingAuthIdentity = await findAuthIdentity(providerId); + return async function signup( + req: Request<{ email: string; password: string }>, + res: Response, + ): Promise> { + const fields = req.body + ensureValidArgs(fields) + + const providerId = createProviderId('email', fields.email) + const existingAuthIdentity = await findAuthIdentity(providerId) + + /** + * + * There are two cases to consider in the case of an existing user: + * - if we allow unverified login + * - if the user is already verified + * + * Let's see what happens when we **don't** allow unverified login: + * + * We are handling the case of an existing auth identity in two ways: + * + * 1. If the user already exists and is verified, we don't want + * to leak that piece of info and instead we pretend that the user + * was created successfully. + * - This prevents the attacker from learning which emails already have + * an account created. + * + * 2. If the user is not verified: + * - We check when we last sent a verification email and if it was less than X seconds ago, + * we don't send another one. + * - If it was more than X seconds ago, we delete the user and create a new one. + * - This prevents the attacker from creating an account with somebody + * else's email address and therefore permanently making that email + * address unavailable for later account creation (by real owner). + */ + if (existingAuthIdentity) { + const providerData = deserializeAndSanitizeProviderData<'email'>( + existingAuthIdentity.providerData, + ) + + // TOOD: faking work makes sense if the time spent on faking the work matches the time + // it would take to send the email. Atm, the fake work takes obviously longer than sending + // the email! + if (providerData.isEmailVerified) { + await doFakeWork() + return res.json({ success: true }) + } - /** - * - * There are two cases to consider in the case of an existing user: - * - if we allow unverified login - * - if the user is already verified - * - * Let's see what happens when we **don't** allow unverified login: - * - * We are handling the case of an existing auth identity in two ways: - * - * 1. If the user already exists and is verified, we don't want - * to leak that piece of info and instead we pretend that the user - * was created successfully. - * - This prevents the attacker from learning which emails already have - * an account created. - * - * 2. If the user is not verified: - * - We check when we last sent a verification email and if it was less than X seconds ago, - * we don't send another one. - * - If it was more than X seconds ago, we delete the user and create a new one. - * - This prevents the attacker from creating an account with somebody - * else's email address and therefore permanently making that email - * address unavailable for later account creation (by real owner). - */ - if (existingAuthIdentity) { - const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData); + // TODO: we are still leaking information here since when we are faking work + // we are not checking if the email was sent or not! + const { isResendAllowed, timeLeft } = isEmailResendAllowed( + providerData, + 'passwordResetSentAt', + ) + if (!isResendAllowed) { + throw new HttpError( + 400, + `Please wait ${timeLeft} secs before trying again.`, + ) + } - // TOOD: faking work makes sense if the time spent on faking the work matches the time - // it would take to send the email. Atm, the fake work takes obviously longer than sending - // the email! - if (providerData.isEmailVerified) { - await doFakeWork(); - return res.json({ success: true }); - } - - // TODO: we are still leaking information here since when we are faking work - // we are not checking if the email was sent or not! - const { isResendAllowed, timeLeft } = isEmailResendAllowed(providerData, 'passwordResetSentAt'); - if (!isResendAllowed) { - throw new HttpError(400, `Please wait ${timeLeft} secs before trying again.`); - } + try { + await deleteUserByAuthId(existingAuthIdentity.authId) + } catch (e: unknown) { + rethrowPossibleAuthError(e) + } + } - try { - await deleteUserByAuthId(existingAuthIdentity.authId); - } catch (e: unknown) { - rethrowPossibleAuthError(e); - } - } + const userFields = await validateAndGetUserFields(fields, userSignupFields) - const userFields = await validateAndGetUserFields( - fields, - userSignupFields, - ); + const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>( + { + hashedPassword: fields.password, + isEmailVerified: isEmailAutoVerified ? true : false, + emailVerificationSentAt: null, + passwordResetSentAt: null, + }, + ) - const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>({ - hashedPassword: fields.password, - isEmailVerified: isEmailAutoVerified ? true : false, - emailVerificationSentAt: null, - passwordResetSentAt: null, - }); + try { + await onBeforeSignupHook({ req, providerId }) + const user = await createUser( + providerId, + newUserProviderData, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any, + ) + await onAfterSignupHook({ req, providerId, user }) + } catch (e: unknown) { + rethrowPossibleAuthError(e) + } - try { - await createUser( - providerId, - newUserProviderData, - // Using any here because we want to avoid TypeScript errors and - // rely on Prisma to validate the data. - userFields as any - ); - } catch (e: unknown) { - rethrowPossibleAuthError(e); - } + // Wasp allows for auto-verification of emails in development mode to + // make writing e2e tests easier. + if (isEmailAutoVerified) { + return res.json({ success: true }) + } - // Wasp allows for auto-verification of emails in development mode to - // make writing e2e tests easier. - if (isEmailAutoVerified) { - return res.json({ success: true }); - } + const verificationLink = await createEmailVerificationLink( + fields.email, + clientRoute, + ) + try { + await sendEmailVerificationEmail(fields.email, { + from: fromField, + to: fields.email, + ...getVerificationEmailContent({ verificationLink }), + }) + } catch (e: unknown) { + console.error('Failed to send email verification email:', e) + throw new HttpError(500, 'Failed to send email verification email.') + } - const verificationLink = await createEmailVerificationLink(fields.email, clientRoute); - try { - await sendEmailVerificationEmail( - fields.email, - { - from: fromField, - to: fields.email, - ...getVerificationEmailContent({ verificationLink }), - } - ); - } catch (e: unknown) { - console.error("Failed to send email verification email:", e); - throw new HttpError(500, "Failed to send email verification email."); - } - - return res.json({ success: true }); - }; + return res.json({ success: true }) + } } function ensureValidArgs(args: unknown): void { - ensureValidEmail(args); - ensurePasswordIsPresent(args); - ensureValidPassword(args); + ensureValidEmail(args) + ensurePasswordIsPresent(args) + ensureValidPassword(args) } - diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/cookies.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/cookies.ts index 88d092f70e..10fdd63984 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/cookies.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/cookies.ts @@ -4,17 +4,17 @@ import { } from "express"; import { parseCookies } from "oslo/cookie"; -import { type ProviderConfig } from "wasp/auth/providers/types"; +import type { ProviderConfig } from "wasp/auth/providers/types"; -import { type StateType } from './state'; +import type { OAuthStateFieldName } from './state'; export function setOAuthCookieValue( provider: ProviderConfig, res: ExpressResponse, - stateType: StateType, + fieldName: OAuthStateFieldName, value: string, -) { - const cookieName = `${provider.id}_${stateType}`; +): void { + const cookieName = `${provider.id}_${fieldName}`; res.cookie(cookieName, value, { httpOnly: true, // TODO: use server config to determine if secure @@ -27,9 +27,9 @@ export function setOAuthCookieValue( export function getOAuthCookieValue( provider: ProviderConfig, req: ExpressRequest, - stateType: StateType, -) { - const cookieName = `${provider.id}_${stateType}`; + fieldName: OAuthStateFieldName, +): string { + const cookieName = `${provider.id}_${fieldName}`; const cookies = parseCookies(req.headers.cookie ?? ""); return cookies.get(cookieName); } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/handler.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/handler.ts index 9fdc36c3b9..869052d7ac 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/handler.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/handler.ts @@ -1,108 +1,125 @@ -import { Router } from "express"; +import { Router } from 'express' -import { handleRejection, redirect } from "wasp/server/utils"; -import { rethrowPossibleAuthError } from "wasp/auth/utils"; -import { type UserSignupFields, type ProviderConfig } from "wasp/auth/providers/types"; +import { handleRejection, redirect } from 'wasp/server/utils' +import { rethrowPossibleAuthError } from 'wasp/auth/utils' +import { + type UserSignupFields, + type ProviderConfig, +} from 'wasp/auth/providers/types' import { - type StateType, + type OAuthType, + type OAuthStateFor, + type OAuthStateWithCodeFor, generateAndStoreOAuthState, validateAndGetOAuthState, -} from "../oauth/state.js"; +} from '../oauth/state.js' +import { finishOAuthFlowAndGetRedirectUri } from '../oauth/user.js' import { - finishOAuthFlowAndGetRedirectUri, + callbackPath, + loginPath, handleOAuthErrorAndGetRedirectUri, -} from "../oauth/user.js"; -import { callbackPath, loginPath } from "./redirect.js"; +} from './redirect.js' +import { onBeforeOAuthRedirectHook } from '../../hooks.js' -export function createOAuthProviderRouter({ +export function createOAuthProviderRouter({ provider, - stateTypes, + oAuthType, userSignupFields, getAuthorizationUrl, + getProviderTokens, getProviderInfo, }: { - provider: ProviderConfig, + provider: ProviderConfig /* - - State is used to validate the callback to ensure the user + - OAuth state is used to validate the callback to ensure the user that requested the login is the same that is completing it. - - It can include just the "state" or an extra "codeVerifier" for PKCE. - - The state types used depend on the provider. + - It includes "state" and an optional "codeVerifier" for PKCE. */ - stateTypes: ST[], - userSignupFields: UserSignupFields | undefined, + oAuthType: OT + userSignupFields: UserSignupFields | undefined /* The function that returns the URL to redirect the user to the provider's login page. */ - getAuthorizationUrl: Parameters>[2], + getAuthorizationUrl: ( + oAuthState: OAuthStateFor, + ) => Promise /* - The function that returns the user's profile and ID from the + The function that returns the access token and refresh token from the provider's callback. */ - getProviderInfo: Parameters>[3], + getProviderTokens: ( + oAuthState: OAuthStateWithCodeFor, + ) => Promise<{ + accessToken: string + }> + /* + The function that returns the user's profile and ID using the access + token. + */ + getProviderInfo: ({ accessToken }: { accessToken: string }) => Promise<{ + providerUserId: string + providerProfile: unknown + }> }): Router { - const router = Router(); + const router = Router() router.get( `/${loginPath}`, - createOAuthLoginHandler(provider, stateTypes, getAuthorizationUrl) + handleRejection(async (req, res) => { + const oAuthState = generateAndStoreOAuthState({ + oAuthType, + provider, + res, + }) + const redirectUrl = await getAuthorizationUrl(oAuthState) + const { url: redirectUrlAfterHook } = await onBeforeOAuthRedirectHook({ + req, + url: redirectUrl, + uniqueRequestId: oAuthState.state, + }) + return redirect(res, redirectUrlAfterHook.toString()) + }), ) router.get( `/${callbackPath}`, - createOAuthCallbackHandler( - provider, - stateTypes, - userSignupFields, - getProviderInfo - ) - ) - - return router; -} - -function createOAuthLoginHandler( - provider: ProviderConfig, - stateTypes: ST[], - getAuthorizationUrl: (oAuthState: ReturnType>) => Promise, -) { - return handleRejection(async (_req, res) => { - const oAuthState = generateAndStoreOAuthState(stateTypes, provider, res); - const url = await getAuthorizationUrl(oAuthState); - return redirect(res, url.toString()); - }) -} - -function createOAuthCallbackHandler( - provider: ProviderConfig, - stateTypes: ST[], - userSignupFields: UserSignupFields | undefined, - getProviderInfo: (oAuthState: ReturnType>) => Promise<{ - providerUserId: string, - providerProfile: unknown, - }>, -) { - return handleRejection(async (req, res) => { - try { - const oAuthState = validateAndGetOAuthState(stateTypes, provider, req); - const { providerProfile, providerUserId } = await getProviderInfo(oAuthState); + handleRejection(async (req, res) => { try { - const redirectUri = await finishOAuthFlowAndGetRedirectUri( + const oAuthState = validateAndGetOAuthState({ + oAuthType, + provider, + req, + }) + const { accessToken } = await getProviderTokens(oAuthState) + + const { providerProfile, providerUserId } = await getProviderInfo({ + accessToken, + }) + try { + const redirectUri = await finishOAuthFlowAndGetRedirectUri({ provider, providerProfile, providerUserId, userSignupFields, - ); - // Redirect to the client with the one time code - return redirect(res, redirectUri.toString()); + req, + accessToken, + oAuthState, + }) + // Redirect to the client with the one time code + return redirect(res, redirectUri.toString()) + } catch (e) { + rethrowPossibleAuthError(e) + } } catch (e) { - rethrowPossibleAuthError(e); + console.error(e) + const redirectUri = handleOAuthErrorAndGetRedirectUri(e) + // Redirect to the client with the error + return redirect(res, redirectUri.toString()) } - } catch (e) { - const redirectUri = handleOAuthErrorAndGetRedirectUri(e); - // Redirect to the client with the error - return redirect(res, redirectUri.toString()); - } - }) + }), + ) + + return router } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/redirect.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/redirect.ts index d3a97b9db9..2408a27b5e 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/redirect.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/redirect.ts @@ -1,5 +1,6 @@ {{={= =}=}} import { config } from 'wasp/server' +import { HttpError } from 'wasp/server' export const loginPath = '{= serverOAuthLoginHandlerPath =}' export const callbackPath = '{= serverOAuthCallbackHandlerPath =}' @@ -14,6 +15,21 @@ export function getRedirectUriForOneTimeCode(oneTimeCode: string): URL { return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}#${oneTimeCode}`); } -export function getRedirectUriForError(error: string): URL { +export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL { + if (error instanceof HttpError) { + const errorMessage = isHttpErrorWithExtraMessage(error) + ? `${error.message}: ${error.data.message}` + : error.message; + return getRedirectUriForError(errorMessage) + } + console.error("Unknown OAuth error:", error); + return getRedirectUriForError("An unknown error occurred while trying to log in with the OAuth provider."); +} + +function getRedirectUriForError(error: string): URL { return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}?error=${error}`); } + +function isHttpErrorWithExtraMessage(error: HttpError): error is HttpError & { data: { message: string } } { + return error.data && typeof (error.data as any).message === 'string'; +} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/state.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/state.ts index ab1b0f8aaa..2c40979e50 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/state.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/state.ts @@ -1,70 +1,144 @@ import { Response as ExpressResponse, Request as ExpressRequest, -} from "express"; -import { generateCodeVerifier, generateState } from "arctic"; +} from 'express'; +import * as arctic from 'arctic'; -import type { ProviderConfig } from "wasp/auth/providers/types"; +import type { ProviderConfig } from 'wasp/auth/providers/types'; -import { setOAuthCookieValue, getOAuthCookieValue } from "./cookies.js"; +import { setOAuthCookieValue, getOAuthCookieValue } from './cookies.js'; -export type StateType = 'state' | 'codeVerifier'; +export type OAuthStateFor< + OT extends OAuthType +> = OAuthStateForOAuthType[OT]; -export function generateAndStoreOAuthState( - stateTypes: ST[], +export type OAuthStateWithCodeFor = OAuthStateFor & OAuthCode + +export type OAuthType = keyof OAuthStateForOAuthType; + +export type OAuthStateFieldName = keyof OAuthState | keyof OAuthStateWithPKCE; + +type OAuthStateForOAuthType = { + OAuth2: OAuthState, + OAuth2WithPKCE: OAuthStateWithPKCE, +}; + +type OAuthState = { + state: string; +}; + +type OAuthStateWithPKCE = { + state: string; + codeVerifier: string; +}; + +/** + * When the OAuth flow is completed, the OAuth provider will redirect the user back to the app + * with a code. This code is then exchanged for an access token. + */ +type OAuthCode = { + code: string; +}; + +export function generateAndStoreOAuthState({ + oAuthType, + provider, + res, +}: { + oAuthType: OT, provider: ProviderConfig, - res: ExpressResponse, -): { [name in ST]: string } { - const result = {} as { [name in StateType]: string } + res: ExpressResponse +}): OAuthStateFor { + const state: OAuthStateFor = { + ...generateState(), + ...(oAuthType === 'OAuth2WithPKCE' && generateCodeVerifier()), + }; - if (stateTypes.includes('state' as ST)) { - const state = generateState(); - setOAuthCookieValue(provider, res, 'state', state); - result.state = state; - } + storeOAuthState(provider, res, state); - if (stateTypes.includes('codeVerifier' as ST)) { - const codeVerifier = generateCodeVerifier(); - setOAuthCookieValue(provider, res, 'codeVerifier', codeVerifier); - result.codeVerifier = codeVerifier; - } + return state; +} + +export function validateAndGetOAuthState({ + oAuthType, + provider, + req, +}: { + oAuthType: OT, + provider: ProviderConfig, + req: ExpressRequest +}): OAuthStateWithCodeFor { + const state: OAuthStateWithCodeFor = { + ...getCode(req), + ...getState(req), + ...(oAuthType === 'OAuth2WithPKCE' && getCodeVerifier(provider, req)), + }; + + validateOAuthState(provider, req, state); - return result; + return state; } -export function validateAndGetOAuthState( - stateTypes: ST[], +function storeOAuthState( + provider: ProviderConfig, + res: ExpressResponse, + state: OAuthStateFor +): void { + let key: keyof typeof state; + for (key in state) { + setOAuthCookieValue(provider, res, key, state[key]); + } +} + +function validateOAuthState( provider: ProviderConfig, req: ExpressRequest, -): { [name in ST]: string } & { code: string } { - const result = {} as { [name in StateType]: string } & { code: string }; - - if (stateTypes.includes('state' as ST)) { - const state = req.query.state; - const storedState = getOAuthCookieValue(provider, req, 'state'); - if ( - !state || - !storedState || - storedState !== state - ) { - throw new Error("Invalid state"); - } - result.state = storedState; + state: OAuthStateWithCodeFor +): void { + if (typeof state.code !== 'string') { + throw new Error('Invalid code'); } - if (stateTypes.includes('codeVerifier' as ST)) { - const storedCodeVerifier = getOAuthCookieValue(provider, req, 'codeVerifier'); - if (!storedCodeVerifier) { - throw new Error("Invalid code verifier"); - } - result.codeVerifier = storedCodeVerifier; + const storedState = getOAuthCookieValue(provider, req, 'state'); + if (!state.state || !storedState || storedState !== state.state) { + throw new Error('Invalid state'); } - const code = req.query.code; - if (typeof code !== "string") { - throw new Error("Invalid code"); + if (isOAuthStateWithPKCE(state) && !state.codeVerifier) { + throw new Error('Missing code verifier'); } - result.code = code; +} + +function generateState(): { state: string } { + return { state: arctic.generateState() }; +} + +function generateCodeVerifier(): { codeVerifier: string } { + return { codeVerifier: arctic.generateCodeVerifier() }; +} + +function getCode(req: ExpressRequest): { code: string } { + return { code: `${req.query.code}` }; +} + +function getState(req: ExpressRequest): { state: string } { + return { state: `${req.query.state}` }; +} + +function getCodeVerifier( + provider: ProviderConfig, + req: ExpressRequest +): { codeVerifier: string } { + const codeVerifier = getOAuthCookieValue( + provider, + req, + 'codeVerifier' + ); + return { codeVerifier }; +} - return result; +function isOAuthStateWithPKCE( + state: OAuthState | OAuthStateWithPKCE +): state is OAuthStateWithPKCE { + return 'codeVerifier' in state; } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/user.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/user.ts index 1c909c3fa9..d0688aae83 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/user.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/user.ts @@ -1,5 +1,5 @@ {{={= =}=}} -import { HttpError } from 'wasp/server' +import { Request as ExpressRequest } from 'express' import { type ProviderId, createUser, @@ -10,46 +10,60 @@ import { import { type {= authEntityUpper =} } from 'wasp/entities' import { prisma } from 'wasp/server' import { type UserSignupFields, type ProviderConfig } from 'wasp/auth/providers/types' -import { getRedirectUriForOneTimeCode, getRedirectUriForError } from './redirect' +import { getRedirectUriForOneTimeCode } from './redirect' import { tokenStore } from './oneTimeCode' +import { onBeforeSignupHook, onAfterSignupHook } from '../../hooks.js'; -export async function finishOAuthFlowAndGetRedirectUri( - provider: ProviderConfig, - providerProfile: unknown, - providerUserId: string, - userSignupFields: UserSignupFields | undefined, -): Promise { +export async function finishOAuthFlowAndGetRedirectUri({ + provider, + providerProfile, + providerUserId, + userSignupFields, + req, + accessToken, + oAuthState, +}: { + provider: ProviderConfig; + providerProfile: unknown; + providerUserId: string; + userSignupFields: UserSignupFields | undefined; + req: ExpressRequest; + accessToken: string; + oAuthState: { state: string }; +}): Promise { const providerId = createProviderId(provider.id, providerUserId); - const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, userSignupFields); + const authId = await getAuthIdFromProviderDetails({ + providerId, + providerProfile, + userSignupFields, + req, + accessToken, + oAuthState, + }); const oneTimeCode = await tokenStore.createToken(authId); return getRedirectUriForOneTimeCode(oneTimeCode); } -export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL { - if (error instanceof HttpError) { - const errorMessage = isHttpErrorWithExtraMessage(error) - ? `${error.message}: ${error.data.message}` - : error.message; - return getRedirectUriForError(errorMessage) - } - console.error("Unknown OAuth error:", error); - return getRedirectUriForError("An unknown error occurred while trying to log in with the OAuth provider."); -} - -function isHttpErrorWithExtraMessage(error: HttpError): error is HttpError & { data: { message: string } } { - return error.data && typeof (error.data as any).message === 'string'; -} - // We need a user id to create the auth token, so we either find an existing user // or create a new one if none exists for this provider. -async function getAuthIdFromProviderDetails( - providerId: ProviderId, - providerProfile: any, - userSignupFields: UserSignupFields | undefined, -): Promise<{= authEntityUpper =}['id']> { +async function getAuthIdFromProviderDetails({ + providerId, + providerProfile, + userSignupFields, + req, + accessToken, + oAuthState, +}: { + providerId: ProviderId; + providerProfile: any; + userSignupFields: UserSignupFields | undefined; + req: ExpressRequest; + accessToken: string; + oAuthState: { state: string }; +}): Promise<{= authEntityUpper =}['id']> { const existingAuthIdentity = await prisma.{= authIdentityEntityLower =}.findUnique({ where: { providerName_providerUserId: providerId, @@ -74,6 +88,7 @@ async function getAuthIdFromProviderDetails( // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. const providerData = await sanitizeAndSerializeProviderData({}) + await onBeforeSignupHook({ req, providerId }) const user = await createUser( providerId, providerData, @@ -81,6 +96,15 @@ async function getAuthIdFromProviderDetails( // rely on Prisma to validate the data. userFields as any, ) + await onAfterSignupHook({ + req, + providerId, + user, + oauth: { + accessToken, + uniqueRequestId: oAuthState.state, + }, + }) return user.auth.id } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts index 6b0fc73162..7648444652 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts @@ -13,6 +13,7 @@ import { } from 'wasp/auth/validation' import { validateAndGetUserFields } from 'wasp/auth/utils' import { type UserSignupFields } from 'wasp/auth/providers/types' +import { onBeforeSignupHook, onAfterSignupHook } from '../../hooks.js'; export function getSignupRoute({ userSignupFields, @@ -34,13 +35,15 @@ export function getSignupRoute({ }) try { - await createUser( + await onBeforeSignupHook({ req, providerId }) + const user = await createUser( providerId, providerData, // Using any here because we want to avoid TypeScript errors and // rely on Prisma to validate the data. userFields as any ) + await onAfterSignupHook({ req, providerId, user }) } catch (e: unknown) { rethrowPossibleAuthError(e) } diff --git a/waspc/e2e-test/Tests/WaspComplexTest.hs b/waspc/e2e-test/Tests/WaspComplexTest.hs index 3a2a25b2b4..f2968e2444 100644 --- a/waspc/e2e-test/Tests/WaspComplexTest.hs +++ b/waspc/e2e-test/Tests/WaspComplexTest.hs @@ -27,7 +27,7 @@ waspComplexTest = do <++> addEmailSender <++> addClientSetup <++> addServerSetup - <++> addGoogleAuth + <++> addAuth <++> sequence [ -- Prerequisite for jobs setDbToPSQL @@ -166,23 +166,26 @@ addServerEnvFile = do "SENDGRID_API_KEY=sendgrid_api_key" ] -addGoogleAuth :: ShellCommandBuilder [ShellCommand] -addGoogleAuth = do +-- Adds Google Auth with auth hooks +addAuth :: ShellCommandBuilder [ShellCommand] +addAuth = do sequence [ insertCodeIntoWaspFileAfterVersion authField, appendToWaspFile userEntity, - appendToWaspFile socialLoginEntity + createFile hooksFile "./src/auth" "hooks.ts" ] where authField = unlines [ " auth: {", " userEntity: User,", - " externalAuthEntity: SocialLogin,", " methods: {", " google: {}", " },", - " onAuthFailedRedirectTo: \"/login\"", + " onAuthFailedRedirectTo: \"/login\",", + " onBeforeSignup: import { onBeforeSignup } from \"@src/auth/hooks.js\",", + " onAfterSignup: import { onAfterSignup } from \"@src/auth/hooks.js\",", + " onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from \"@src/auth/hooks.js\",", " }," ] @@ -190,23 +193,35 @@ addGoogleAuth = do unlines [ "entity User {=psl", " id Int @id @default(autoincrement())", - " username String @unique", - " password String", - " externalAuthAssociations SocialLogin[]", "psl=}" ] - socialLoginEntity = + hooksFile = unlines - [ "entity SocialLogin {=psl", - " id Int @id @default(autoincrement())", - " provider String", - " providerId String", - " user User @relation(fields: [userId], references: [id], onDelete: Cascade)", - " userId Int", - " createdAt DateTime @default(now())", - " @@unique([provider, providerId, userId])", - "psl=}" + [ "import type {", + " OnAfterSignupHook,", + " OnBeforeOAuthRedirectHook,", + " OnBeforeSignupHook,", + "} from 'wasp/server/auth'", + "", + "export const onBeforeSignup: OnBeforeSignupHook = async (args) => {", + " const count = await args.prisma.user.count()", + " console.log('before', count)", + " console.log(args.providerId)", + "}", + "", + "export const onAfterSignup: OnAfterSignupHook = async (args) => {", + " const count = await args.prisma.user.count()", + " console.log('after', count)", + " console.log('user', args.user)", + "}", + "", + "export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async (", + " args,", + ") => {", + " console.log('redirect to', args.url.toString())", + " return { url: args.url }", + "}" ] addAction :: ShellCommandBuilder [ShellCommand] diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest index 37c42af8fd..0e5948919c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest @@ -223,6 +223,9 @@ waspComplexTest/.wasp/out/sdk/wasp/dist/entities/index.js.map waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/MainPage.d.ts waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/MainPage.jsx waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/MainPage.jsx.map +waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.d.ts +waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js +waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js.map waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/client/App.d.ts waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/client/App.jsx waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/client/App.jsx.map @@ -265,6 +268,9 @@ waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/taggedEntities.js.map waspComplexTest/.wasp/out/sdk/wasp/dist/server/api/index.d.ts waspComplexTest/.wasp/out/sdk/wasp/dist/server/api/index.js waspComplexTest/.wasp/out/sdk/wasp/dist/server/api/index.js.map +waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.d.ts +waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js +waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js.map waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.d.ts waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.js waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.js.map @@ -364,6 +370,7 @@ waspComplexTest/.wasp/out/sdk/wasp/dist/universal/validators.js.map waspComplexTest/.wasp/out/sdk/wasp/entities/index.ts waspComplexTest/.wasp/out/sdk/wasp/ext-src/Main.css waspComplexTest/.wasp/out/sdk/wasp/ext-src/MainPage.jsx +waspComplexTest/.wasp/out/sdk/wasp/ext-src/auth/hooks.ts waspComplexTest/.wasp/out/sdk/wasp/ext-src/client/App.jsx waspComplexTest/.wasp/out/sdk/wasp/ext-src/client/myClientSetupCode.js waspComplexTest/.wasp/out/sdk/wasp/ext-src/server/actions/bar.js @@ -381,6 +388,7 @@ waspComplexTest/.wasp/out/sdk/wasp/server/_types/index.ts waspComplexTest/.wasp/out/sdk/wasp/server/_types/serialization.ts waspComplexTest/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts waspComplexTest/.wasp/out/sdk/wasp/server/api/index.ts +waspComplexTest/.wasp/out/sdk/wasp/server/auth/hooks.ts waspComplexTest/.wasp/out/sdk/wasp/server/auth/index.ts waspComplexTest/.wasp/out/sdk/wasp/server/auth/user.ts waspComplexTest/.wasp/out/sdk/wasp/server/config.ts @@ -424,6 +432,7 @@ waspComplexTest/.wasp/out/server/rollup.config.js waspComplexTest/.wasp/out/server/scripts/validate-env.mjs waspComplexTest/.wasp/out/server/src/actions/mySpecialAction.ts waspComplexTest/.wasp/out/server/src/app.js +waspComplexTest/.wasp/out/server/src/auth/hooks.ts waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts waspComplexTest/.wasp/out/server/src/auth/providers/index.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/config.ts @@ -487,6 +496,7 @@ waspComplexTest/package.json waspComplexTest/public/.gitkeep waspComplexTest/src/Main.css waspComplexTest/src/MainPage.jsx +waspComplexTest/src/auth/hooks.ts waspComplexTest/src/client/App.jsx waspComplexTest/src/client/myClientSetupCode.js waspComplexTest/src/server/actions/bar.js diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums index e856aec406..b3011bdd00 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums @@ -375,7 +375,7 @@ "file", "../out/sdk/wasp/entities/index.ts" ], - "cdd9cdbeebfdad8c54ddbf4978a3c4974faa66df05b87b42a5030fcc214394f8" + "becac9b39c6920dee40a97958331da143e06dd26d6756ad08dcc6402b23e2efb" ], [ [ @@ -391,6 +391,13 @@ ], "bd452ce5b469d3b48e69705daaa07e339d9d0e9d88a99a74c2ace9fa6d6299e7" ], + [ + [ + "file", + "../out/sdk/wasp/ext-src/auth/hooks.ts" + ], + "80a8edba2313a81dfd01eb952a09c02fba22554206601342289de60d6f377291" + ], [ [ "file", @@ -487,7 +494,7 @@ "file", "../out/sdk/wasp/server/_types/index.ts" ], - "7a9bd946ac8b55a110a6d1ed93040c1ec4def2104b0de58961b56c678d31ab91" + "f436c855b151a95179fd59193b8fe426ae0138265be3cef450e430302c3aeafb" ], [ [ @@ -501,7 +508,7 @@ "file", "../out/sdk/wasp/server/_types/taggedEntities.ts" ], - "6df33c5f432a8931138b4605bc2f3d84ac1ec130cc6233edc258321ed7c8da48" + "1b07d45c9a0b34e75cac0e0dd58f1ad89b03c1d38363198b355387819607d3da" ], [ [ @@ -510,19 +517,26 @@ ], "57fcd47ce2ee3ffdff22513f14ba023da0f47577bc3950d5f9ea7dc6e981d26d" ], + [ + [ + "file", + "../out/sdk/wasp/server/auth/hooks.ts" + ], + "0a33f1809cfbcf86e00c4cd1d349192f686a4a8324120c90b2e53381078fda2a" + ], [ [ "file", "../out/sdk/wasp/server/auth/index.ts" ], - "61745079707aa8a4134e140b5cf33d5c9977d997da924de606cf606871438455" + "4d44320b8bdfb4dfc07664f813677420319f0610071e73c5b62be96ad35dd959" ], [ [ "file", "../out/sdk/wasp/server/auth/user.ts" ], - "6c026b896fe346b8e0ccc188d21f86f17f0d482800a793b590df326821c33a7f" + "eb5754af49265911b6423004068cd829cf2e4b1b6a25e2477318f7e07e3029c9" ], [ [ @@ -767,7 +781,7 @@ "file", "db/schema.prisma" ], - "196026a80ee5430c1a4e01746f66d46d44ce21464cc8f580e0995d19034530d7" + "b34e2464f4def0f712e73cfc683e80db7d7c9c00ce4208d305bb2a524f20ae4a" ], [ [ @@ -839,12 +853,19 @@ ], "a10bb6f3daab886b0685994368711fd7718a2a3e50a9329cd7da6394b9c59302" ], + [ + [ + "file", + "server/src/auth/hooks.ts" + ], + "c9e80d960136127dbd5a1cfc955d08323c40d5733221e2bd2bb28e12b85e274b" + ], [ [ "file", "server/src/auth/providers/config/google.ts" ], - "d5546bace955d33579dc25fd9b88689435e8d572b1190d18ffc0276521e2db86" + "d53d1fb541e3ef3f2b2996731c4da7ef5db0568aed39f03f1de3f9b6d3d30471" ], [ [ @@ -865,7 +886,7 @@ "file", "server/src/auth/providers/oauth/cookies.ts" ], - "3887cc320c8084201db087ab5d8ede9c72816f99803e66b4178960ca22091bbc" + "7fb6658564e8af442d0f0b5874879e6788e91e89d98a8265621dd323135f5916" ], [ [ @@ -879,7 +900,7 @@ "file", "server/src/auth/providers/oauth/handler.ts" ], - "d243cf5465c41a2409f2ae6d032e3678dbad7507376ba41c8f300be6bc104b2b" + "7ca4820825cb4cc445c9161c391f2aaefe7c0d072e84fd9569f8d607e2bf2a28" ], [ [ @@ -893,14 +914,14 @@ "file", "server/src/auth/providers/oauth/redirect.ts" ], - "1aabb58c599dae1e8a47d139c80464331ab87cfefa5a712df4ef594b7876a2a5" + "099d0b7aee0eafd258f4e656f405816e3f0d26dcaa705d97d3c9044f67824146" ], [ [ "file", "server/src/auth/providers/oauth/state.ts" ], - "b50a6b92cb4bb30a62587b26b0b8ef9fda0f1ad656b74640771e7e232757b677" + "65b028a240f5eaf7b4553be328b784ccffef05792a1fd5f438b795c506292fde" ], [ [ @@ -914,7 +935,7 @@ "file", "server/src/auth/providers/oauth/user.ts" ], - "eaea0f07dee02a72e6ee26ea363adae66930c97659855bdfc3703d075ec73253" + "260527c819fee29840447730e057391dca3205fc8cbb6c491db580d8722a7875" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma index 598fb4270d..dff118ea19 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma @@ -10,21 +10,8 @@ generator client { model User { id Int @id @default(autoincrement()) - username String @unique - password String - externalAuthAssociations SocialLogin[] auth Auth? -} -model SocialLogin { - id Int @id @default(autoincrement()) - provider String - providerId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - createdAt DateTime @default(now()) - @@unique([provider, providerId, userId]) - } model Task { id Int @id @default(autoincrement()) diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum index 717e139d94..ce8aec12f0 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum @@ -1 +1 @@ -196026a80ee5430c1a4e01746f66d46d44ce21464cc8f580e0995d19034530d7 \ No newline at end of file +b34e2464f4def0f712e73cfc683e80db7d7c9c00ce4208d305bb2a524f20ae4a \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/entities/index.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/entities/index.d.ts index 6b1fce51a1..4650f97199 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/entities/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/entities/index.d.ts @@ -1,4 +1,4 @@ -import { type User, type SocialLogin, type Task } from "@prisma/client"; -export { type User, type SocialLogin, type Task, type Auth, type AuthIdentity, } from "@prisma/client"; -export type Entity = User | SocialLogin | Task | never; -export type EntityName = "User" | "SocialLogin" | "Task" | never; +import { type User, type Task } from "@prisma/client"; +export { type User, type Task, type Auth, type AuthIdentity, } from "@prisma/client"; +export type Entity = User | Task | never; +export type EntityName = "User" | "Task" | never; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.d.ts new file mode 100644 index 0000000000..2baf554c49 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.d.ts @@ -0,0 +1,4 @@ +import type { OnAfterSignupHook, OnBeforeOAuthRedirectHook, OnBeforeSignupHook } from 'wasp/server/auth'; +export declare const onBeforeSignup: OnBeforeSignupHook; +export declare const onAfterSignup: OnAfterSignupHook; +export declare const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js new file mode 100644 index 0000000000..4d20706453 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js @@ -0,0 +1,15 @@ +export const onBeforeSignup = async (args) => { + const count = await args.prisma.user.count(); + console.log('before', count); + console.log(args.providerId); +}; +export const onAfterSignup = async (args) => { + const count = await args.prisma.user.count(); + console.log('after', count); + console.log('user', args.user); +}; +export const onBeforeOAuthRedirect = async (args) => { + console.log('redirect to', args.url.toString()); + return { url: args.url }; +}; +//# sourceMappingURL=hooks.js.map \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js.map b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js.map new file mode 100644 index 0000000000..f1d237f75e --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/ext-src/auth/hooks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../../ext-src/auth/hooks.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,cAAc,GAAuB,KAAK,EAAE,IAAI,EAAE,EAAE;IAC/D,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,CAAA;IAC5C,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IAC5B,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;AAC9B,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,aAAa,GAAsB,KAAK,EAAE,IAAI,EAAE,EAAE;IAC7D,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,CAAA;IAC5C,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAA;IAC3B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;AAChC,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,qBAAqB,GAA8B,KAAK,EACnE,IAAI,EACJ,EAAE;IACF,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC/C,OAAO,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAA;AAC1B,CAAC,CAAA"} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/index.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/index.d.ts index fdc7e8ff67..47869a7468 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/index.d.ts @@ -20,7 +20,6 @@ type EntityMap = { }; export type PrismaDelegate = { "User": typeof prisma.user; - "SocialLogin": typeof prisma.socialLogin; "Task": typeof prisma.task; }; type Context = Expand<{ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/taggedEntities.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/taggedEntities.d.ts index 1e72d72e8f..8a62d8ba1a 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/taggedEntities.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/_types/taggedEntities.d.ts @@ -1,8 +1,7 @@ -import { type Entity, type EntityName, type User, type SocialLogin, type Task } from 'wasp/entities'; +import { type Entity, type EntityName, type User, type Task } from 'wasp/entities'; export type _User = WithName; -export type _SocialLogin = WithName; export type _Task = WithName; -export type _Entity = _User | _SocialLogin | _Task | never; +export type _Entity = _User | _Task | never; type WithName = E & { _entityName: Name; }; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.d.ts new file mode 100644 index 0000000000..e0ae748ad2 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.d.ts @@ -0,0 +1,69 @@ +import type { Request as ExpressRequest } from 'express'; +import type { ProviderId, createUser } from '../../auth/utils.js'; +import { prisma } from '../index.js'; +import { Expand } from '../../universal/types.js'; +export type OnBeforeSignupHook = (params: Expand) => void | Promise; +export type OnAfterSignupHook = (params: Expand) => void | Promise; +/** + * @returns Object with a URL that the OAuth flow should redirect to. + */ +export type OnBeforeOAuthRedirectHook = (params: Expand) => { + url: URL; +} | Promise<{ + url: URL; +}>; +export type InternalAuthHookParams = { + /** + * Prisma instance that can be used to interact with the database. + */ + prisma: typeof prisma; +}; +type OnBeforeSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId; + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest; +} & InternalAuthHookParams; +type OnAfterSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId; + /** + * User object that was created during the signup process. + */ + user: Awaited>; + oauth?: { + /** + * Access token that was received during the OAuth flow. + */ + accessToken: string; + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string; + }; + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest; +} & InternalAuthHookParams; +type OnBeforeOAuthRedirectHookParams = { + /** + * URL that the OAuth flow should redirect to. + */ + url: URL; + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string; + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest; +} & InternalAuthHookParams; +export {}; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js new file mode 100644 index 0000000000..1dde2a0993 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=hooks.js.map \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js.map b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js.map new file mode 100644 index 0000000000..6481d76540 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/hooks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"hooks.js","sourceRoot":"","sources":["../../../server/auth/hooks.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.d.ts index 7c2a33ba59..c6b98cec57 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/index.d.ts @@ -1,3 +1,4 @@ export { defineUserSignupFields, } from '../../auth/providers/types.js'; export { createProviderId, sanitizeAndSerializeProviderData, updateAuthIdentityProviderData, deserializeAndSanitizeProviderData, findAuthIdentity, createUser, type ProviderId, type ProviderName, type EmailProviderData, type UsernameProviderData, type OAuthProviderData, } from '../../auth/utils.js'; export { ensurePasswordIsPresent, ensureValidPassword, ensureTokenIsPresent, } from '../../auth/validation.js'; +export type { OnBeforeSignupHook, OnAfterSignupHook, OnBeforeOAuthRedirectHook, InternalAuthHookParams, } from './hooks.js'; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/user.d.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/user.d.ts index 0285d6a044..c3a0bd9be4 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/user.d.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/dist/server/auth/user.d.ts @@ -5,18 +5,6 @@ import { Expand } from '../../universal/types.js'; export type AuthUser = AuthUserData & { getFirstProviderUserId: () => string | null; }; -/** - * Ideally, we'd do something like this: - * ``` - * export type AuthUserData = ReturnType - * ``` - * to get the benefits of the createAuthUser and the AuthUserData type being in sync. - * - * But since we are not using strict mode, the inferred return type of createAuthUser - * is not correct. So we have to define the AuthUserData type manually. - * - * TODO: Change this once/if we switch to strict mode. https://github.com/wasp-lang/wasp/issues/1938 - */ export type AuthUserData = Omit & { identities: { google: Expand> | null; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/entities/index.ts index d5eec2c3a2..5febac3804 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/entities/index.ts @@ -1,12 +1,10 @@ import { type User, - type SocialLogin, type Task, } from "@prisma/client" export { type User, - type SocialLogin, type Task, type Auth, type AuthIdentity, @@ -14,12 +12,10 @@ export { export type Entity = | User - | SocialLogin | Task | never export type EntityName = | "User" - | "SocialLogin" | "Task" | never diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/ext-src/auth/hooks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/ext-src/auth/hooks.ts new file mode 100644 index 0000000000..f986ff056b --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/ext-src/auth/hooks.ts @@ -0,0 +1,25 @@ +import type { + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + OnBeforeSignupHook, +} from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async (args) => { + const count = await args.prisma.user.count() + console.log('before', count) + console.log(args.providerId) +} + +export const onAfterSignup: OnAfterSignupHook = async (args) => { + const count = await args.prisma.user.count() + console.log('after', count) + console.log('user', args.user) +} + +export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async ( + args, +) => { + console.log('redirect to', args.url.toString()) + return { url: args.url } +} + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/index.ts index b106e87212..55e2db32ed 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/index.ts @@ -71,7 +71,6 @@ type EntityMap = { export type PrismaDelegate = { "User": typeof prisma.user, - "SocialLogin": typeof prisma.socialLogin, "Task": typeof prisma.task, } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts index d1d69e6af8..c82affed3b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/_types/taggedEntities.ts @@ -7,17 +7,14 @@ import { type Entity, type EntityName, type User, - type SocialLogin, type Task, } from 'wasp/entities' export type _User = WithName -export type _SocialLogin = WithName export type _Task = WithName export type _Entity = | _User - | _SocialLogin | _Task | never diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/hooks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/hooks.ts new file mode 100644 index 0000000000..35aeca279e --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/hooks.ts @@ -0,0 +1,87 @@ +import type { Request as ExpressRequest } from 'express' +import type { ProviderId, createUser } from '../../auth/utils.js' +import { prisma } from '../index.js' +import { Expand } from '../../universal/types.js' + +// PUBLIC API +export type OnBeforeSignupHook = ( + params: Expand, +) => void | Promise + +// PUBLIC API +export type OnAfterSignupHook = ( + params: Expand, +) => void | Promise + +// PUBLIC API +/** + * @returns Object with a URL that the OAuth flow should redirect to. + */ +export type OnBeforeOAuthRedirectHook = ( + params: Expand, +) => { url: URL } | Promise<{ url: URL }> + +// PRIVATE API (used in the SDK and the server) +export type InternalAuthHookParams = { + /** + * Prisma instance that can be used to interact with the database. + */ + prisma: typeof prisma +} + +// NOTE: We should be exporting types that can be reached by users via other +// exported types (e.g. using the Parameters Typescript helper). +// However, we are not exporting this type to keep the API surface smaller. +// This type is only used internally by the SDK. Exporting it might confuse +// users since the name is too similar to the exported function type. +// Same goes for all other *Params types in this file. +type OnBeforeSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams + +type OnAfterSignupHookParams = { + /** + * Provider ID object that contains the provider name and the provide user ID. + */ + providerId: ProviderId + /** + * User object that was created during the signup process. + */ + user: Awaited> + oauth?: { + /** + * Access token that was received during the OAuth flow. + */ + accessToken: string + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string + }, + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams + +type OnBeforeOAuthRedirectHookParams = { + /** + * URL that the OAuth flow should redirect to. + */ + url: URL + /** + * Unique request ID that was generated during the OAuth flow. + */ + uniqueRequestId: string + /** + * Request object that can be used to access the incoming request. + */ + req: ExpressRequest +} & InternalAuthHookParams diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/index.ts index 181ba4fcf6..251f23fecd 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/index.ts @@ -22,4 +22,11 @@ export { ensureTokenIsPresent, } from '../../auth/validation.js' +export type { + OnBeforeSignupHook, + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + InternalAuthHookParams, +} from './hooks.js' + diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/user.ts index d56fed5d01..f6e9c624f5 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/sdk/wasp/server/auth/user.ts @@ -16,7 +16,7 @@ export type AuthUser = AuthUserData & { } // PRIVATE API -/** +/* * Ideally, we'd do something like this: * ``` * export type AuthUserData = ReturnType diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/hooks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/hooks.ts new file mode 100644 index 0000000000..1e5ca356f5 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/hooks.ts @@ -0,0 +1,49 @@ +import { prisma } from 'wasp/server' +import type { + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + OnBeforeSignupHook, + InternalAuthHookParams, +} from 'wasp/server/auth' +import { onBeforeSignup as onBeforeSignupHook_ext } from '../../../../../src/auth/hooks.js' +import { onAfterSignup as onAfterSignupHook_ext } from '../../../../../src/auth/hooks.js' +import { onBeforeOAuthRedirect as onBeforeOAuthRedirectHook_ext } from '../../../../../src/auth/hooks.js' + +/* + These are "internal hook functions" based on the user defined hook functions. + + In the server code (e.g. email signup) we import these functions and call them. + + We want to pass extra params to the user defined hook functions, but we don't want to + pass them when we call them in the server code. +*/ + +export const onBeforeSignupHook: InternalFunctionForHook = (params) => + onBeforeSignupHook_ext({ + prisma, + ...params, + }) + +export const onAfterSignupHook: InternalFunctionForHook = (params) => + onAfterSignupHook_ext({ + prisma, + ...params, + }) + +export const onBeforeOAuthRedirectHook: InternalFunctionForHook = (params) => + onBeforeOAuthRedirectHook_ext({ + prisma, + ...params, + }) + +/* + We pass extra params to the user defined hook functions, but we don't want to + pass the extra params (e.g. 'prisma') when we call the hooks in the server code. + So, we need to remove the extra params from the params object which is used to define the + internal hook functions. +*/ +type InternalFunctionForHook unknown | Promise> = Fn extends ( + params: infer P, +) => infer R + ? (args: Omit) => R + : never diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts index 769b8bd690..dae5b818a6 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts @@ -53,13 +53,11 @@ const _waspConfig: ProviderConfig = { return createOAuthProviderRouter({ provider, - stateTypes: ['state', 'codeVerifier'], + oAuthType: 'OAuth2WithPKCE', userSignupFields: _waspUserSignupFields, getAuthorizationUrl: ({ state, codeVerifier }) => google.createAuthorizationURL(state, codeVerifier, config), - getProviderInfo: async ({ code, codeVerifier }) => { - const { accessToken } = await google.validateAuthorizationCode(code, codeVerifier); - return getGoogleProfile(accessToken); - }, + getProviderTokens: ({ code, codeVerifier }) => google.validateAuthorizationCode(code, codeVerifier), + getProviderInfo: ({ accessToken }) => getGoogleProfile(accessToken), }); }, } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/cookies.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/cookies.ts index 88d092f70e..10fdd63984 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/cookies.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/cookies.ts @@ -4,17 +4,17 @@ import { } from "express"; import { parseCookies } from "oslo/cookie"; -import { type ProviderConfig } from "wasp/auth/providers/types"; +import type { ProviderConfig } from "wasp/auth/providers/types"; -import { type StateType } from './state'; +import type { OAuthStateFieldName } from './state'; export function setOAuthCookieValue( provider: ProviderConfig, res: ExpressResponse, - stateType: StateType, + fieldName: OAuthStateFieldName, value: string, -) { - const cookieName = `${provider.id}_${stateType}`; +): void { + const cookieName = `${provider.id}_${fieldName}`; res.cookie(cookieName, value, { httpOnly: true, // TODO: use server config to determine if secure @@ -27,9 +27,9 @@ export function setOAuthCookieValue( export function getOAuthCookieValue( provider: ProviderConfig, req: ExpressRequest, - stateType: StateType, -) { - const cookieName = `${provider.id}_${stateType}`; + fieldName: OAuthStateFieldName, +): string { + const cookieName = `${provider.id}_${fieldName}`; const cookies = parseCookies(req.headers.cookie ?? ""); return cookies.get(cookieName); } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/handler.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/handler.ts index 9fdc36c3b9..869052d7ac 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/handler.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/handler.ts @@ -1,108 +1,125 @@ -import { Router } from "express"; +import { Router } from 'express' -import { handleRejection, redirect } from "wasp/server/utils"; -import { rethrowPossibleAuthError } from "wasp/auth/utils"; -import { type UserSignupFields, type ProviderConfig } from "wasp/auth/providers/types"; +import { handleRejection, redirect } from 'wasp/server/utils' +import { rethrowPossibleAuthError } from 'wasp/auth/utils' +import { + type UserSignupFields, + type ProviderConfig, +} from 'wasp/auth/providers/types' import { - type StateType, + type OAuthType, + type OAuthStateFor, + type OAuthStateWithCodeFor, generateAndStoreOAuthState, validateAndGetOAuthState, -} from "../oauth/state.js"; +} from '../oauth/state.js' +import { finishOAuthFlowAndGetRedirectUri } from '../oauth/user.js' import { - finishOAuthFlowAndGetRedirectUri, + callbackPath, + loginPath, handleOAuthErrorAndGetRedirectUri, -} from "../oauth/user.js"; -import { callbackPath, loginPath } from "./redirect.js"; +} from './redirect.js' +import { onBeforeOAuthRedirectHook } from '../../hooks.js' -export function createOAuthProviderRouter({ +export function createOAuthProviderRouter({ provider, - stateTypes, + oAuthType, userSignupFields, getAuthorizationUrl, + getProviderTokens, getProviderInfo, }: { - provider: ProviderConfig, + provider: ProviderConfig /* - - State is used to validate the callback to ensure the user + - OAuth state is used to validate the callback to ensure the user that requested the login is the same that is completing it. - - It can include just the "state" or an extra "codeVerifier" for PKCE. - - The state types used depend on the provider. + - It includes "state" and an optional "codeVerifier" for PKCE. */ - stateTypes: ST[], - userSignupFields: UserSignupFields | undefined, + oAuthType: OT + userSignupFields: UserSignupFields | undefined /* The function that returns the URL to redirect the user to the provider's login page. */ - getAuthorizationUrl: Parameters>[2], + getAuthorizationUrl: ( + oAuthState: OAuthStateFor, + ) => Promise /* - The function that returns the user's profile and ID from the + The function that returns the access token and refresh token from the provider's callback. */ - getProviderInfo: Parameters>[3], + getProviderTokens: ( + oAuthState: OAuthStateWithCodeFor, + ) => Promise<{ + accessToken: string + }> + /* + The function that returns the user's profile and ID using the access + token. + */ + getProviderInfo: ({ accessToken }: { accessToken: string }) => Promise<{ + providerUserId: string + providerProfile: unknown + }> }): Router { - const router = Router(); + const router = Router() router.get( `/${loginPath}`, - createOAuthLoginHandler(provider, stateTypes, getAuthorizationUrl) + handleRejection(async (req, res) => { + const oAuthState = generateAndStoreOAuthState({ + oAuthType, + provider, + res, + }) + const redirectUrl = await getAuthorizationUrl(oAuthState) + const { url: redirectUrlAfterHook } = await onBeforeOAuthRedirectHook({ + req, + url: redirectUrl, + uniqueRequestId: oAuthState.state, + }) + return redirect(res, redirectUrlAfterHook.toString()) + }), ) router.get( `/${callbackPath}`, - createOAuthCallbackHandler( - provider, - stateTypes, - userSignupFields, - getProviderInfo - ) - ) - - return router; -} - -function createOAuthLoginHandler( - provider: ProviderConfig, - stateTypes: ST[], - getAuthorizationUrl: (oAuthState: ReturnType>) => Promise, -) { - return handleRejection(async (_req, res) => { - const oAuthState = generateAndStoreOAuthState(stateTypes, provider, res); - const url = await getAuthorizationUrl(oAuthState); - return redirect(res, url.toString()); - }) -} - -function createOAuthCallbackHandler( - provider: ProviderConfig, - stateTypes: ST[], - userSignupFields: UserSignupFields | undefined, - getProviderInfo: (oAuthState: ReturnType>) => Promise<{ - providerUserId: string, - providerProfile: unknown, - }>, -) { - return handleRejection(async (req, res) => { - try { - const oAuthState = validateAndGetOAuthState(stateTypes, provider, req); - const { providerProfile, providerUserId } = await getProviderInfo(oAuthState); + handleRejection(async (req, res) => { try { - const redirectUri = await finishOAuthFlowAndGetRedirectUri( + const oAuthState = validateAndGetOAuthState({ + oAuthType, + provider, + req, + }) + const { accessToken } = await getProviderTokens(oAuthState) + + const { providerProfile, providerUserId } = await getProviderInfo({ + accessToken, + }) + try { + const redirectUri = await finishOAuthFlowAndGetRedirectUri({ provider, providerProfile, providerUserId, userSignupFields, - ); - // Redirect to the client with the one time code - return redirect(res, redirectUri.toString()); + req, + accessToken, + oAuthState, + }) + // Redirect to the client with the one time code + return redirect(res, redirectUri.toString()) + } catch (e) { + rethrowPossibleAuthError(e) + } } catch (e) { - rethrowPossibleAuthError(e); + console.error(e) + const redirectUri = handleOAuthErrorAndGetRedirectUri(e) + // Redirect to the client with the error + return redirect(res, redirectUri.toString()) } - } catch (e) { - const redirectUri = handleOAuthErrorAndGetRedirectUri(e); - // Redirect to the client with the error - return redirect(res, redirectUri.toString()); - } - }) + }), + ) + + return router } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/redirect.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/redirect.ts index 35c1cb4fd0..be677f0ff6 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/redirect.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/redirect.ts @@ -1,4 +1,5 @@ import { config } from 'wasp/server' +import { HttpError } from 'wasp/server' export const loginPath = 'login' export const callbackPath = 'callback' @@ -13,6 +14,21 @@ export function getRedirectUriForOneTimeCode(oneTimeCode: string): URL { return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}#${oneTimeCode}`); } -export function getRedirectUriForError(error: string): URL { +export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL { + if (error instanceof HttpError) { + const errorMessage = isHttpErrorWithExtraMessage(error) + ? `${error.message}: ${error.data.message}` + : error.message; + return getRedirectUriForError(errorMessage) + } + console.error("Unknown OAuth error:", error); + return getRedirectUriForError("An unknown error occurred while trying to log in with the OAuth provider."); +} + +function getRedirectUriForError(error: string): URL { return new URL(`${config.frontendUrl}${clientOAuthCallbackPath}?error=${error}`); } + +function isHttpErrorWithExtraMessage(error: HttpError): error is HttpError & { data: { message: string } } { + return error.data && typeof (error.data as any).message === 'string'; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/state.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/state.ts index ab1b0f8aaa..2c40979e50 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/state.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/state.ts @@ -1,70 +1,144 @@ import { Response as ExpressResponse, Request as ExpressRequest, -} from "express"; -import { generateCodeVerifier, generateState } from "arctic"; +} from 'express'; +import * as arctic from 'arctic'; -import type { ProviderConfig } from "wasp/auth/providers/types"; +import type { ProviderConfig } from 'wasp/auth/providers/types'; -import { setOAuthCookieValue, getOAuthCookieValue } from "./cookies.js"; +import { setOAuthCookieValue, getOAuthCookieValue } from './cookies.js'; -export type StateType = 'state' | 'codeVerifier'; +export type OAuthStateFor< + OT extends OAuthType +> = OAuthStateForOAuthType[OT]; -export function generateAndStoreOAuthState( - stateTypes: ST[], +export type OAuthStateWithCodeFor = OAuthStateFor & OAuthCode + +export type OAuthType = keyof OAuthStateForOAuthType; + +export type OAuthStateFieldName = keyof OAuthState | keyof OAuthStateWithPKCE; + +type OAuthStateForOAuthType = { + OAuth2: OAuthState, + OAuth2WithPKCE: OAuthStateWithPKCE, +}; + +type OAuthState = { + state: string; +}; + +type OAuthStateWithPKCE = { + state: string; + codeVerifier: string; +}; + +/** + * When the OAuth flow is completed, the OAuth provider will redirect the user back to the app + * with a code. This code is then exchanged for an access token. + */ +type OAuthCode = { + code: string; +}; + +export function generateAndStoreOAuthState({ + oAuthType, + provider, + res, +}: { + oAuthType: OT, provider: ProviderConfig, - res: ExpressResponse, -): { [name in ST]: string } { - const result = {} as { [name in StateType]: string } + res: ExpressResponse +}): OAuthStateFor { + const state: OAuthStateFor = { + ...generateState(), + ...(oAuthType === 'OAuth2WithPKCE' && generateCodeVerifier()), + }; - if (stateTypes.includes('state' as ST)) { - const state = generateState(); - setOAuthCookieValue(provider, res, 'state', state); - result.state = state; - } + storeOAuthState(provider, res, state); - if (stateTypes.includes('codeVerifier' as ST)) { - const codeVerifier = generateCodeVerifier(); - setOAuthCookieValue(provider, res, 'codeVerifier', codeVerifier); - result.codeVerifier = codeVerifier; - } + return state; +} + +export function validateAndGetOAuthState({ + oAuthType, + provider, + req, +}: { + oAuthType: OT, + provider: ProviderConfig, + req: ExpressRequest +}): OAuthStateWithCodeFor { + const state: OAuthStateWithCodeFor = { + ...getCode(req), + ...getState(req), + ...(oAuthType === 'OAuth2WithPKCE' && getCodeVerifier(provider, req)), + }; + + validateOAuthState(provider, req, state); - return result; + return state; } -export function validateAndGetOAuthState( - stateTypes: ST[], +function storeOAuthState( + provider: ProviderConfig, + res: ExpressResponse, + state: OAuthStateFor +): void { + let key: keyof typeof state; + for (key in state) { + setOAuthCookieValue(provider, res, key, state[key]); + } +} + +function validateOAuthState( provider: ProviderConfig, req: ExpressRequest, -): { [name in ST]: string } & { code: string } { - const result = {} as { [name in StateType]: string } & { code: string }; - - if (stateTypes.includes('state' as ST)) { - const state = req.query.state; - const storedState = getOAuthCookieValue(provider, req, 'state'); - if ( - !state || - !storedState || - storedState !== state - ) { - throw new Error("Invalid state"); - } - result.state = storedState; + state: OAuthStateWithCodeFor +): void { + if (typeof state.code !== 'string') { + throw new Error('Invalid code'); } - if (stateTypes.includes('codeVerifier' as ST)) { - const storedCodeVerifier = getOAuthCookieValue(provider, req, 'codeVerifier'); - if (!storedCodeVerifier) { - throw new Error("Invalid code verifier"); - } - result.codeVerifier = storedCodeVerifier; + const storedState = getOAuthCookieValue(provider, req, 'state'); + if (!state.state || !storedState || storedState !== state.state) { + throw new Error('Invalid state'); } - const code = req.query.code; - if (typeof code !== "string") { - throw new Error("Invalid code"); + if (isOAuthStateWithPKCE(state) && !state.codeVerifier) { + throw new Error('Missing code verifier'); } - result.code = code; +} + +function generateState(): { state: string } { + return { state: arctic.generateState() }; +} + +function generateCodeVerifier(): { codeVerifier: string } { + return { codeVerifier: arctic.generateCodeVerifier() }; +} + +function getCode(req: ExpressRequest): { code: string } { + return { code: `${req.query.code}` }; +} + +function getState(req: ExpressRequest): { state: string } { + return { state: `${req.query.state}` }; +} + +function getCodeVerifier( + provider: ProviderConfig, + req: ExpressRequest +): { codeVerifier: string } { + const codeVerifier = getOAuthCookieValue( + provider, + req, + 'codeVerifier' + ); + return { codeVerifier }; +} - return result; +function isOAuthStateWithPKCE( + state: OAuthState | OAuthStateWithPKCE +): state is OAuthStateWithPKCE { + return 'codeVerifier' in state; } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/user.ts index 279dbb5f69..6a8a09cc59 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/user.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/user.ts @@ -1,4 +1,4 @@ -import { HttpError } from 'wasp/server' +import { Request as ExpressRequest } from 'express' import { type ProviderId, createUser, @@ -9,46 +9,60 @@ import { import { type Auth } from 'wasp/entities' import { prisma } from 'wasp/server' import { type UserSignupFields, type ProviderConfig } from 'wasp/auth/providers/types' -import { getRedirectUriForOneTimeCode, getRedirectUriForError } from './redirect' +import { getRedirectUriForOneTimeCode } from './redirect' import { tokenStore } from './oneTimeCode' +import { onBeforeSignupHook, onAfterSignupHook } from '../../hooks.js'; -export async function finishOAuthFlowAndGetRedirectUri( - provider: ProviderConfig, - providerProfile: unknown, - providerUserId: string, - userSignupFields: UserSignupFields | undefined, -): Promise { +export async function finishOAuthFlowAndGetRedirectUri({ + provider, + providerProfile, + providerUserId, + userSignupFields, + req, + accessToken, + oAuthState, +}: { + provider: ProviderConfig; + providerProfile: unknown; + providerUserId: string; + userSignupFields: UserSignupFields | undefined; + req: ExpressRequest; + accessToken: string; + oAuthState: { state: string }; +}): Promise { const providerId = createProviderId(provider.id, providerUserId); - const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, userSignupFields); + const authId = await getAuthIdFromProviderDetails({ + providerId, + providerProfile, + userSignupFields, + req, + accessToken, + oAuthState, + }); const oneTimeCode = await tokenStore.createToken(authId); return getRedirectUriForOneTimeCode(oneTimeCode); } -export function handleOAuthErrorAndGetRedirectUri(error: unknown): URL { - if (error instanceof HttpError) { - const errorMessage = isHttpErrorWithExtraMessage(error) - ? `${error.message}: ${error.data.message}` - : error.message; - return getRedirectUriForError(errorMessage) - } - console.error("Unknown OAuth error:", error); - return getRedirectUriForError("An unknown error occurred while trying to log in with the OAuth provider."); -} - -function isHttpErrorWithExtraMessage(error: HttpError): error is HttpError & { data: { message: string } } { - return error.data && typeof (error.data as any).message === 'string'; -} - // We need a user id to create the auth token, so we either find an existing user // or create a new one if none exists for this provider. -async function getAuthIdFromProviderDetails( - providerId: ProviderId, - providerProfile: any, - userSignupFields: UserSignupFields | undefined, -): Promise { +async function getAuthIdFromProviderDetails({ + providerId, + providerProfile, + userSignupFields, + req, + accessToken, + oAuthState, +}: { + providerId: ProviderId; + providerProfile: any; + userSignupFields: UserSignupFields | undefined; + req: ExpressRequest; + accessToken: string; + oAuthState: { state: string }; +}): Promise { const existingAuthIdentity = await prisma.authIdentity.findUnique({ where: { providerName_providerUserId: providerId, @@ -73,6 +87,7 @@ async function getAuthIdFromProviderDetails( // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. const providerData = await sanitizeAndSerializeProviderData({}) + await onBeforeSignupHook({ req, providerId }) const user = await createUser( providerId, providerData, @@ -80,6 +95,15 @@ async function getAuthIdFromProviderDetails( // rely on Prisma to validate the data. userFields as any, ) + await onAfterSignupHook({ + req, + providerId, + user, + oauth: { + accessToken, + uniqueRequestId: oAuthState.state, + }, + }) return user.auth.id } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp index 64b103210d..6bb62e9b28 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/main.wasp @@ -5,11 +5,13 @@ app waspComplexTest { }, auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { google: {} }, - onAuthFailedRedirectTo: "/login" + onAuthFailedRedirectTo: "/login", + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks.js", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks.js", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks.js", }, server: { @@ -38,19 +40,6 @@ page MainPage { } entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String - externalAuthAssociations SocialLogin[] -psl=} - -entity SocialLogin {=psl - id Int @id @default(autoincrement()) - provider String - providerId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - createdAt DateTime @default(now()) - @@unique([provider, providerId, userId]) psl=} job mySpecialJob { diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/auth/hooks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/auth/hooks.ts new file mode 100644 index 0000000000..f986ff056b --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/src/auth/hooks.ts @@ -0,0 +1,25 @@ +import type { + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + OnBeforeSignupHook, +} from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async (args) => { + const count = await args.prisma.user.count() + console.log('before', count) + console.log(args.providerId) +} + +export const onAfterSignup: OnAfterSignupHook = async (args) => { + const count = await args.prisma.user.count() + console.log('after', count) + console.log('user', args.user) +} + +export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async ( + args, +) => { + console.log('redirect to', args.url.toString()) + return { url: args.url } +} + diff --git a/waspc/examples/todoApp/main.wasp b/waspc/examples/todoApp/main.wasp index f5761a9c89..a00c2df31f 100644 --- a/waspc/examples/todoApp/main.wasp +++ b/waspc/examples/todoApp/main.wasp @@ -40,7 +40,10 @@ app todoApp { }, }, onAuthFailedRedirectTo: "/login", - onAuthSucceededRedirectTo: "/profile" + onAuthSucceededRedirectTo: "/profile", + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks.js", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks.js", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks.js", }, server: { setupFn: import setup from "@src/serverSetup", diff --git a/waspc/examples/todoApp/src/auth/hooks.ts b/waspc/examples/todoApp/src/auth/hooks.ts new file mode 100644 index 0000000000..92b9da51c4 --- /dev/null +++ b/waspc/examples/todoApp/src/auth/hooks.ts @@ -0,0 +1,54 @@ +import { Request } from 'express' +import type { + OnAfterSignupHook, + OnBeforeOAuthRedirectHook, + OnBeforeSignupHook, +} from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async (args) => { + const log = createLoggerForHook('onBeforeSignup') + const count = await args.prisma.user.count() + log('number of users before', count) + log('providerId object', args.providerId) +} + +const oAuthQueryStore = new Map() + +export const onAfterSignup: OnAfterSignupHook = async (args) => { + const log = createLoggerForHook('onAfterSignup') + const count = await args.prisma.user.count() + log('number of users after', count) + log('user object', args.user) + log('providerId object', args.providerId) + + // If this is a OAuth signup, we have access token and uniqueRequestId + if (args.oauth) { + log('accessToken', args.oauth.accessToken) + log('uniqueRequestId', args.oauth.uniqueRequestId) + const id = args.oauth.uniqueRequestId + const query = oAuthQueryStore.get(id) + if (query) { + log('saved query params after oAuth redirect', query) + } + oAuthQueryStore.delete(id) + } +} + +export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async ( + args +) => { + const log = createLoggerForHook('onBeforeOAuthRedirect') + log('query params before oAuth redirect', args.req.query) + + // Saving query params for later use in onAfterSignup hook + const id = args.uniqueRequestId + oAuthQueryStore.set(id, args.req.query) + + return { url: args.url } +} + +function createLoggerForHook(hookName: string) { + return (...args: unknown[]) => { + console.log(`[${hookName}]`, ...args) + } +} diff --git a/waspc/headless-test/examples/todoApp/migrations/20240508125445_add_headless_test_property/migration.sql b/waspc/headless-test/examples/todoApp/migrations/20240508125445_add_headless_test_property/migration.sql new file mode 100644 index 0000000000..5c83e174db --- /dev/null +++ b/waspc/headless-test/examples/todoApp/migrations/20240508125445_add_headless_test_property/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isOnAfterSignupHookCalled" BOOLEAN NOT NULL DEFAULT false; diff --git a/waspc/headless-test/examples/todoApp/src/auth/hooks.ts b/waspc/headless-test/examples/todoApp/src/auth/hooks.ts new file mode 100644 index 0000000000..fd0a11a240 --- /dev/null +++ b/waspc/headless-test/examples/todoApp/src/auth/hooks.ts @@ -0,0 +1,20 @@ +import { HttpError } from 'wasp/server' +import type { + OnAfterSignupHook, + OnBeforeSignupHook, +} from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async (args) => { + if (args.providerId.providerUserId === 'notallowed@email.com') { + throw new HttpError(403, 'On Before Signup Hook disallows this email.') + } +} + +export const onAfterSignup: OnAfterSignupHook = async (args) => { + await args.prisma.user.update({ + where: { id: args.user.id }, + data: { + isOnAfterSignupHookCalled: true, + }, + }) +} diff --git a/waspc/headless-test/examples/todoApp/src/client/pages/ProfilePage.tsx b/waspc/headless-test/examples/todoApp/src/client/pages/ProfilePage.tsx index d2c486d99d..d0e06f0c9a 100644 --- a/waspc/headless-test/examples/todoApp/src/client/pages/ProfilePage.tsx +++ b/waspc/headless-test/examples/todoApp/src/client/pages/ProfilePage.tsx @@ -19,12 +19,14 @@ export const ProfilePage = ({ user }: { user: User }) => {
Hello {user.getFirstProviderUserId()}! Your status is{' '} - {user.identities.email && user.identities.email.isEmailVerified - ? 'verfied' - : 'unverified'} + {user.identities.email?.isEmailVerified ? 'verfied' : 'unverified'} .
+
+ Value of user.isOnAfterSignupHookCalled is{' '} + {user.isOnAfterSignupHookCalled ? 'true' : 'false'}. +

Go to dashboard diff --git a/waspc/headless-test/examples/todoApp/todoApp.wasp b/waspc/headless-test/examples/todoApp/todoApp.wasp index 518fad8bf0..d172ef4f14 100644 --- a/waspc/headless-test/examples/todoApp/todoApp.wasp +++ b/waspc/headless-test/examples/todoApp/todoApp.wasp @@ -23,7 +23,9 @@ app todoApp { google: {} }, onAuthFailedRedirectTo: "/login", - onAuthSucceededRedirectTo: "/profile" + onAuthSucceededRedirectTo: "/profile", + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks.js", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks.js", }, server: { setupFn: import setup from "@src/server/serverSetup.js", @@ -50,6 +52,7 @@ app todoApp { entity User {=psl id Int @id @default(autoincrement()) + isOnAfterSignupHookCalled Boolean @default(false) // Business logic tasks Task[] psl=} diff --git a/waspc/headless-test/tests/auth-hooks.spec.ts b/waspc/headless-test/tests/auth-hooks.spec.ts new file mode 100644 index 0000000000..d8a8d00f45 --- /dev/null +++ b/waspc/headless-test/tests/auth-hooks.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test' +import { + generateRandomCredentials, + performLogin, + performSignup, +} from './helpers' + +test.describe('auth hooks', () => { + test.describe.configure({ mode: 'serial' }) + + /* + We set up the "before signup hook" to throw an error for a specific email address. + */ + test('before signup hook works', async ({ page }) => { + const emailThatThrowsError = 'notallowed@email.com' + const password = '12345678' + + await performSignup(page, { + email: emailThatThrowsError, + password, + }) + + await expect(page.locator('body')).toContainText( + 'On Before Signup Hook disallows this email.', + ) + }) + + /* + We set up the "after signup hook" to set a value in the user object. + */ + test('after signup hook works', async ({ page }) => { + const { email, password } = generateRandomCredentials() + + await performSignup(page, { + email, + password, + }) + + await performLogin(page, { + email, + password, + }) + + await expect(page).toHaveURL('/profile') + + await expect(page.locator('body')).toContainText( + 'Value of user.isOnAfterSignupHookCalled is true.', + ) + }) +}) diff --git a/waspc/headless-test/tests/helpers.ts b/waspc/headless-test/tests/helpers.ts new file mode 100644 index 0000000000..e16cba34ca --- /dev/null +++ b/waspc/headless-test/tests/helpers.ts @@ -0,0 +1,43 @@ +import type { Page } from '@playwright/test' + +export async function performSignup( + page: Page, + { email, password }: { email: string; password: string }, +) { + await page.goto('/signup') + + await page.waitForSelector('text=Create a new account') + + await page.locator("input[type='email']").fill(email) + await page.locator("input[type='password']").fill(password) + await page.locator('button').click() +} + +export async function performLogin( + page: Page, + { + email, + password, + }: { + email: string + password: string + }, +) { + await page.goto('/login') + + await page.waitForSelector('text=Log in to your account') + + await page.locator("input[type='email']").fill(email) + await page.locator("input[type='password']").fill(password) + await page.getByRole('button', { name: 'Log in' }).click() +} + +export function generateRandomCredentials(): { + email: string + password: string +} { + return { + email: `test${Math.random().toString(36).substring(7)}@test.com`, + password: '12345678', + } +} diff --git a/waspc/headless-test/tests/simple.spec.ts b/waspc/headless-test/tests/simple.spec.ts index 3109bf9efb..d84560e366 100644 --- a/waspc/headless-test/tests/simple.spec.ts +++ b/waspc/headless-test/tests/simple.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from '@playwright/test' +import { + generateRandomCredentials, + performLogin, + performSignup, +} from './helpers' test('has title', async ({ page }) => { await page.goto('/') @@ -6,8 +11,7 @@ test('has title', async ({ page }) => { await expect(page).toHaveTitle(/ToDo App/) }) test.describe('signup and login', () => { - const randomEmail = `test${Math.random().toString(36).substring(7)}@test.com` - const password = '12345678' + const { email, password } = generateRandomCredentials() test.describe.configure({ mode: 'serial' }) @@ -22,13 +26,10 @@ test.describe('signup and login', () => { }) test('can sign up', async ({ page }) => { - await page.goto('/signup') - - await page.waitForSelector('text=Create a new account') - - await page.locator("input[type='email']").fill(randomEmail) - await page.locator("input[type='password']").fill(password) - await page.locator('button').click() + await performSignup(page, { + email, + password, + }) await expect(page.locator('body')).toContainText( `You've signed up successfully! Check your email for the confirmation link.`, @@ -36,24 +37,23 @@ test.describe('signup and login', () => { }) test('can log in and create a task', async ({ page }) => { - await page.goto('/login') - - await page.waitForSelector('text=Log in to your account') - - await page.locator("input[type='email']").fill(randomEmail) - await page.locator("input[type='password']").fill('12345678xxx') - await page.getByRole('button', { name: 'Log in' }).click() + await performLogin(page, { + email, + password: '12345678xxx', + }) - await expect(page.locator('body')).toContainText(`Invalid credentials`) + await expect(page.locator('body')).toContainText('Invalid credentials') - await page.locator("input[type='password']").fill(password) - await page.getByRole('button', { name: 'Log in' }).click() + await performLogin(page, { + email, + password, + }) await expect(page).toHaveURL('/profile') await page.goto('/') - const randomTask = 'New Task ' + Math.random().toString(36).substring(7) + const randomTask = `New Task ${Math.random().toString(36).substring(7)}` await page.locator("input[type='text']").fill(randomTask) await page.getByText('Create new task').click() diff --git a/waspc/headless-test/tests/user-api.spec.ts b/waspc/headless-test/tests/user-api.spec.ts index 6fd61a969f..f64e474575 100644 --- a/waspc/headless-test/tests/user-api.spec.ts +++ b/waspc/headless-test/tests/user-api.spec.ts @@ -1,22 +1,18 @@ import { test, expect } from '@playwright/test' +import { generateRandomCredentials, performSignup } from './helpers' test.describe('user API', () => { - const randomEmail = `test${Math.random().toString(36).substring(7)}@test.com` - const password = '12345678' + const { email, password } = generateRandomCredentials() test.describe.configure({ mode: 'serial' }) test.beforeAll(async ({ browser }) => { const page = await browser.newPage() - // Sign up - await page.goto('/signup') - - await page.waitForSelector('text=Create a new account') - - await page.locator("input[type='email']").fill(randomEmail) - await page.locator("input[type='password']").fill(password) - await page.locator('button').click() + await performSignup(page, { + email, + password, + }) }) test('user API works on the client', async ({ page }) => { @@ -24,16 +20,16 @@ test.describe('user API', () => { await page.waitForSelector('text=Log in to your account') - await page.locator("input[type='email']").fill(randomEmail) + await page.locator("input[type='email']").fill(email) await page.locator("input[type='password']").fill(password) await page.getByRole('button', { name: 'Log in' }).click() await page.waitForSelector('text=Profile page') await expect(page.locator('body')).toContainText( - `Hello ${randomEmail}! Your status is verfied`, + `Hello ${email}! Your status is verfied`, ) - await expect(page.locator('a[href="/profile"]')).toContainText(randomEmail) + await expect(page.locator('a[href="/profile"]')).toContainText(email) }) }) diff --git a/waspc/src/Wasp/AppSpec/App/Auth.hs b/waspc/src/Wasp/AppSpec/App/Auth.hs index 24da6dc4d8..5c5b2dfa03 100644 --- a/waspc/src/Wasp/AppSpec/App/Auth.hs +++ b/waspc/src/Wasp/AppSpec/App/Auth.hs @@ -33,7 +33,10 @@ data Auth = Auth externalAuthEntity :: Maybe (Ref Entity), methods :: AuthMethods, onAuthFailedRedirectTo :: String, - onAuthSucceededRedirectTo :: Maybe String + onAuthSucceededRedirectTo :: Maybe String, + onBeforeSignup :: Maybe ExtImport, + onAfterSignup :: Maybe ExtImport, + onBeforeOAuthRedirect :: Maybe ExtImport } deriving (Show, Eq, Data) diff --git a/waspc/src/Wasp/Generator/SdkGenerator/Server/AuthG.hs b/waspc/src/Wasp/Generator/SdkGenerator/Server/AuthG.hs index 28eec292cc..9689736115 100644 --- a/waspc/src/Wasp/Generator/SdkGenerator/Server/AuthG.hs +++ b/waspc/src/Wasp/Generator/SdkGenerator/Server/AuthG.hs @@ -25,7 +25,8 @@ genNewServerApi spec = Just auth -> sequence [ genAuthIndex auth, - genAuthUser auth + genAuthUser auth, + genFileCopy [relfile|server/auth/hooks.ts|] ] <++> genAuthEmail auth <++> genAuthUsername auth diff --git a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs index a4e01ed570..d7a546d2f7 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/AuthG.hs @@ -7,8 +7,11 @@ where import Data.Aeson (object, (.=)) import Data.Maybe (fromJust) import StrongPath - ( File', + ( Dir, + File', + Path, Path', + Posix, Rel, reldirP, relfile, @@ -37,6 +40,7 @@ import Wasp.Generator.ServerGenerator.Auth.EmailAuthG (genEmailAuth) import Wasp.Generator.ServerGenerator.Auth.LocalAuthG (genLocalAuth) import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (genOAuthAuth) import qualified Wasp.Generator.ServerGenerator.Common as C +import Wasp.Generator.ServerGenerator.JsImport (extImportToAliasedImportJson) import qualified Wasp.JsImport as JI import Wasp.Util ((<++>)) @@ -48,7 +52,8 @@ genAuth spec = case maybeAuth of [ genAuthRoutesIndex auth, genFileCopy [relfile|routes/auth/me.ts|], genFileCopy [relfile|routes/auth/logout.ts|], - genProvidersIndex auth + genProvidersIndex auth, + genAuthHooks auth ] <++> genLocalAuth auth <++> genOAuthAuth auth @@ -96,6 +101,22 @@ genProvidersIndex auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers JI._importAlias = Nothing } +genAuthHooks :: AS.Auth.Auth -> Generator FileDraft +genAuthHooks auth = return $ C.mkTmplFdWithData [relfile|src/auth/hooks.ts|] (Just tmplData) + where + tmplData = + object + [ "onBeforeSignupHook" .= onBeforeSignupHook, + "onAfterSignupHook" .= onAfterSignupHook, + "onBeforeOAuthRedirectHook" .= onBeforeOAuthRedirectHook + ] + onBeforeSignupHook = extImportToAliasedImportJson "onBeforeSignupHook_ext" relPathToServerSrcDir $ AS.Auth.onBeforeSignup auth + onAfterSignupHook = extImportToAliasedImportJson "onAfterSignupHook_ext" relPathToServerSrcDir $ AS.Auth.onAfterSignup auth + onBeforeOAuthRedirectHook = extImportToAliasedImportJson "onBeforeOAuthRedirectHook_ext" relPathToServerSrcDir $ AS.Auth.onBeforeOAuthRedirect auth + + relPathToServerSrcDir :: Path Posix (Rel importLocation) (Dir C.ServerSrcDir) + relPathToServerSrcDir = [reldirP|../|] + depsRequiredByAuth :: AppSpec -> [AS.Dependency.Dependency] depsRequiredByAuth spec = maybe [] (const authDeps) maybeAuth where diff --git a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs index 292264f898..ae4f39e845 100644 --- a/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs +++ b/waspc/src/Wasp/Generator/ServerGenerator/JsImport.hs @@ -22,6 +22,16 @@ extImportToImportJson pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImpo where jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport +extImportToAliasedImportJson :: + JsImportAlias -> + Path Posix (Rel importLocation) (Dir ServerSrcDir) -> + Maybe EI.ExtImport -> + Aeson.Value +extImportToAliasedImportJson importAlias pathFromImportLocationToSrcDir maybeExtImport = GJI.jsImportToImportJson aliasedJsImport + where + jsImport = extImportToJsImport pathFromImportLocationToSrcDir <$> maybeExtImport + aliasedJsImport = JI.applyJsImportAlias (Just importAlias) <$> jsImport + getJsImportStmtAndIdentifier :: Path Posix (Rel importLocation) (Dir ServerSrcDir) -> EI.ExtImport -> diff --git a/waspc/test/AnalyzerTest.hs b/waspc/test/AnalyzerTest.hs index bcfe9a0831..1e18b7f905 100644 --- a/waspc/test/AnalyzerTest.hs +++ b/waspc/test/AnalyzerTest.hs @@ -149,7 +149,10 @@ spec_Analyzer = do Auth.email = Nothing }, Auth.onAuthFailedRedirectTo = "/", - Auth.onAuthSucceededRedirectTo = Nothing + Auth.onAuthSucceededRedirectTo = Nothing, + Auth.onBeforeSignup = Nothing, + Auth.onAfterSignup = Nothing, + Auth.onBeforeOAuthRedirect = Nothing }, App.server = Just diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index 8ce7255dd3..f7585c53fd 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -115,7 +115,10 @@ spec_AppSpecValid = do AS.Auth.email = Nothing }, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing, + AS.Auth.onBeforeSignup = Nothing, + AS.Auth.onAfterSignup = Nothing, + AS.Auth.onBeforeOAuthRedirect = Nothing } describe "should validate that when a page has authRequired, app.auth is also set." $ do @@ -158,7 +161,10 @@ spec_AppSpecValid = do AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, AS.Auth.externalAuthEntity = Nothing, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing, + AS.Auth.onBeforeSignup = Nothing, + AS.Auth.onAfterSignup = Nothing, + AS.Auth.onBeforeOAuthRedirect = Nothing }, AS.App.emailSender = Just @@ -295,7 +301,10 @@ spec_AppSpecValid = do AS.Auth.userEntity = AS.Core.Ref.Ref userEntityName, AS.Auth.externalAuthEntity = Nothing, AS.Auth.onAuthFailedRedirectTo = "/", - AS.Auth.onAuthSucceededRedirectTo = Nothing + AS.Auth.onAuthSucceededRedirectTo = Nothing, + AS.Auth.onBeforeSignup = Nothing, + AS.Auth.onAfterSignup = Nothing, + AS.Auth.onBeforeOAuthRedirect = Nothing }, AS.App.emailSender = emailSender }, diff --git a/web/docs/auth/auth-hooks.md b/web/docs/auth/auth-hooks.md new file mode 100644 index 0000000000..4a41992e7d --- /dev/null +++ b/web/docs/auth/auth-hooks.md @@ -0,0 +1,569 @@ +--- +title: Auth Hooks +--- + +import { EmailPill, UsernameAndPasswordPill, GithubPill, GooglePill, KeycloakPill } from "./Pills"; +import ImgWithCaption from '@site/blog/components/ImgWithCaption' + +Auth hooks allow you to "hook into" the auth process at various stages and run your custom code. For example, if you want to forbid certain emails from signing up, or if you wish to send a welcome email to the user after they sign up, auth hooks are the way to go. + +## Supported hooks + +The following auth hooks are available in Wasp: +- [`onBeforeSignup`](#executing-code-before-the-user-signs-up) +- [`onAfterSignup`](#executing-code-after-the-user-signs-up) +- [`onBeforeOAuthRedirect`](#executing-code-before-the-oauth-redirect) + +We'll go through each of these hooks in detail. But first, let's see how the hooks fit into the signup flow: + + + +If you are using OAuth, the flow includes extra steps before the signup flow: + + + +## Using hooks + +To use auth hooks, you must first declare them in the Wasp file: + + + + +```wasp +app myApp { + wasp: { + version: "^0.13.0" + }, + auth: { + userEntity: User, + methods: { + ... + }, + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + + + +```wasp +app myApp { + wasp: { + version: "^0.13.0" + }, + auth: { + userEntity: User, + methods: { + ... + }, + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + + + +If the hooks are defined as async functions, Wasp _awaits_ them. This means the auth process waits for the hooks to finish before continuing. + +Wasp ignores the hooks' return values. The only exception is the `onBeforeOAuthRedirect` hook, whose return value affects the OAuth redirect URL. + +We'll now go through each of the available hooks. + +### Executing code before the user signs up + +Wasp calls the `onBeforeSignup` hook before the user is created. + +The `onBeforeSignup` hook can be useful if you want to reject a user based on some criteria before they sign up. + +Works with + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + }, +} +``` + +```js title="src/auth/hooks.js" +import { HttpError } from 'wasp/server' + +export const onBeforeSignup = async ({ + providerId, + prisma, + req, +}) => { + const count = await prisma.user.count() + console.log('number of users before', count) + console.log('provider name', providerId.providerName) + console.log('provider user ID', providerId.providerUserId) + + if (count > 100) { + throw new HttpError(403, 'Too many users') + } + + if (providerId.providerName === 'email' && providerId.providerUserId === 'some@email.com') { + throw new HttpError(403, 'This email is not allowed') + } +} +``` + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + }, +} +``` + +```ts title="src/auth/hooks.ts" +import { HttpError } from 'wasp/server' +import type { OnBeforeSignupHook } from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async ({ + providerId, + prisma, + req, +}) => { + const count = await prisma.user.count() + console.log('number of users before', count) + console.log('provider name', providerId.providerName) + console.log('provider user ID', providerId.providerUserId) + + if (count > 100) { + throw new HttpError(403, 'Too many users') + } + + if (providerId.providerName === 'email' && providerId.providerUserId === 'some@email.com') { + throw new HttpError(403, 'This email is not allowed') + } +} +``` + + + + +Read more about the data the `onBeforeSignup` hook receives in the [API Reference](#the-onbeforesignup-hook). + +### Executing code after the user signs up + +Wasp calls the `onAfterSignup` hook after the user is created. + +The `onAfterSignup` hook can be useful if you want to send the user a welcome email or perform some other action after the user signs up like syncing the user with a third-party service. + +Since the `onAfterSignup` hook receives the OAuth access token, it can also be used to store the OAuth access token for the user in your database. + +Works with + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + }, +} +``` + +```js title="src/auth/hooks.js" +export const onAfterSignup = async ({ + providerId, + user, + oauth, + prisma, + req, +}) => { + const count = await prisma.user.count() + console.log('number of users after', count) + console.log('user object', user) + + // If this is an OAuth signup, we have the access token and uniqueRequestId + if (oauth) { + console.log('accessToken', oauth.accessToken) + console.log('uniqueRequestId', oauth.uniqueRequestId) + + const id = oauth.uniqueRequestId + const data = someKindOfStore.get(id) + if (data) { + console.log('saved data for the ID', data) + } + someKindOfStore.delete(id) + } +} +``` + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + }, +} +``` + +```ts title="src/auth/hooks.ts" +import type { OnAfterSignupHook } from 'wasp/server/auth' + +export const onAfterSignup: OnAfterSignupHook = async ({ + providerId, + user, + oauth, + prisma, + req, +}) => { + const count = await prisma.user.count() + console.log('number of users after', count) + console.log('user object', user) + + // If this is an OAuth signup, we have the access token and uniqueRequestId + if (oauth) { + console.log('accessToken', oauth.accessToken) + console.log('uniqueRequestId', oauth.uniqueRequestId) + + const id = oauth.uniqueRequestId + const data = someKindOfStore.get(id) + if (data) { + console.log('saved data for the ID', data) + } + someKindOfStore.delete(id) + } +} +``` + + + + +Read more about the data the `onAfterSignup` hook receives in the [API Reference](#the-onaftersignup-hook). + +### Executing code before the OAuth redirect + +Wasp calls the `onBeforeOAuthRedirect` hook after the OAuth redirect URL is generated but before redirecting the user. This hook can access the request object sent from the client at the start of the OAuth process. + +The `onBeforeOAuthRedirect` hook can be useful if you want to save some data (e.g. request query parameters) that can be used later in the OAuth flow. You can use the `uniqueRequestId` parameter to reference this data later in the `onAfterSignup` hook. + +Works with + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + +```js title="src/auth/hooks.js" +export const onBeforeOAuthRedirect = async ({ + url, + uniqueRequestId, + prisma, + req, +}) => { + console.log('query params before oAuth redirect', req.query) + + // Saving query params for later use in the onAfterSignup hook + const id = uniqueRequestId + someKindOfStore.set(id, req.query) + + return { url } +} +``` + + + + +```wasp title="main.wasp" +app myApp { + ... + auth: { + ... + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + +```ts title="src/auth/hooks.ts" +import type { OnBeforeOAuthRedirectHook } from 'wasp/server/auth' + +export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async ({ + url, + uniqueRequestId, + prisma, + req, +}) => { + console.log('query params before oAuth redirect', req.query) + + // Saving query params for later use in the onAfterSignup hook + const id = uniqueRequestId + someKindOfStore.set(id, req.query) + + return { url } +} +``` + + + + +This hook's return value must be an object that looks like this: `{ url: URL }`. Wasp uses the URL to redirect the user to the OAuth provider. + +Read more about the data the `onBeforeOAuthRedirect` hook receives in the [API Reference](#the-onbeforeoauthredirect-hook). + +## API Reference + + + + +```wasp +app myApp { + wasp: { + version: "^0.13.0" + }, + auth: { + userEntity: User, + methods: { + ... + }, + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + + + +```wasp +app myApp { + wasp: { + version: "^0.13.0" + }, + auth: { + userEntity: User, + methods: { + ... + }, + onBeforeSignup: import { onBeforeSignup } from "@src/auth/hooks", + onAfterSignup: import { onAfterSignup } from "@src/auth/hooks", + onBeforeOAuthRedirect: import { onBeforeOAuthRedirect } from "@src/auth/hooks", + }, +} +``` + + + +### The `onBeforeSignup` hook + + + + +```js title="src/auth/hooks.js" +import { HttpError } from 'wasp/server' + +export const onBeforeSignup = async ({ + providerId, + prisma, + req, +}) => { + // Hook code here +} +``` + + + + +```ts title="src/auth/hooks.ts" +import { HttpError } from 'wasp/server' +import type { OnBeforeSignupHook } from 'wasp/server/auth' + +export const onBeforeSignup: OnBeforeSignupHook = async ({ + providerId, + prisma, + req, +}) => { + // Hook code here +} +``` + + + + +The hook receives an object as **input** with the following properties: + +- `providerId: ProviderId` + + The user's provider ID is an object with two properties: + - `providerName: string` + + The provider's name (e.g. `'email'`, `'google'`, `'github'`) + - `providerUserId: string` + + The user's unique ID in the provider's system (e.g. email, Google ID, GitHub ID) +- `prisma: PrismaClient` + + The Prisma client instance which you can use to query your database. +- `req: Request` + + The [Express request object](https://expressjs.com/en/api.html#req) from which you can access the request headers, cookies, etc. + +Wasp ignores this hook's **return value**. + +### The `onAfterSignup` hook + + + + +```js title="src/auth/hooks.js" +export const onAfterSignup = async ({ + providerId, + user, + oauth, + prisma, + req, +}) => { + // Hook code here +} +``` + + + + +```ts title="src/auth/hooks.ts" +import type { OnAfterSignupHook } from 'wasp/server/auth' + +export const onAfterSignup: OnAfterSignupHook = async ({ + providerId, + user, + oauth, + prisma, + req, +}) => { + // Hook code here +} +``` + + + + +The hook receives an object as **input** with the following properties: +- `providerId: ProviderId` + + The user's provider ID is an object with two properties: + - `providerName: string` + + The provider's name (e.g. `'email'`, `'google'`, `'github'`) + - `providerUserId: string` + + The user's unique ID in the provider's system (e.g. email, Google ID, GitHub ID) +- `user: User` + + The user object that was created. +- `oauth?: OAuthFields` + + This object is present only when the user is created using [Social Auth](./social-auth/overview.md). + It contains the following fields: + - `accessToken: string` + + You can use the OAuth access token to use the provider's API on user's behalf. + - `uniqueRequestId: string` + + The unique request ID for the OAuth flow (you might know it as the `state` parameter in OAuth.) + + You can use the unique request ID to get the data saved in the `onBeforeOAuthRedirect` hook. +- `prisma: PrismaClient` + + The Prisma client instance which you can use to query your database. +- `req: Request` + + The [Express request object](https://expressjs.com/en/api.html#req) from which you can access the request headers, cookies, etc. + +Wasp ignores this hook's **return value**. + +### The `onBeforeOAuthRedirect` hook + + + + +```js title="src/auth/hooks.js" +export const onBeforeOAuthRedirect = async ({ + url, + uniqueRequestId, + prisma, + req, +}) => { + // Hook code here + + return { url } +} +``` + + + + +```ts title="src/auth/hooks.ts" +import type { OnBeforeOAuthRedirectHook } from 'wasp/server/auth' + +export const onBeforeOAuthRedirect: OnBeforeOAuthRedirectHook = async ({ + url, + uniqueRequestId, + prisma, + req, +}) => { + // Hook code here + + return { url } +} +``` + + + + +The hook receives an object as **input** with the following properties: +- `url: URL` + + Wasp uses the URL for the OAuth redirect. +- `uniqueRequestId: string` + + The unique request ID for the OAuth flow (you might know it as the `state` parameter in OAuth.) + + You can use the unique request ID to save data (e.g. request query params) that you can later use in the `onAfterSignup` hook. +- `prisma: PrismaClient` + + The Prisma client instance which you can use to query your database. +- `req: Request` + + The [Express request object](https://expressjs.com/en/api.html#req) from which you can access the request headers, cookies, etc. + +This hook's return value must be an object that looks like this: `{ url: URL }`. Wasp uses the URL to redirect the user to the OAuth provider. diff --git a/web/sidebars.js b/web/sidebars.js index a239034c0c..4bdd9c95dc 100644 --- a/web/sidebars.js +++ b/web/sidebars.js @@ -74,6 +74,7 @@ module.exports = { ], }, 'auth/entities/entities', + 'auth/auth-hooks' ], }, { diff --git a/web/static/img/auth-hooks/oauth_flow_with_hooks.png b/web/static/img/auth-hooks/oauth_flow_with_hooks.png new file mode 100644 index 0000000000..1efd73c612 Binary files /dev/null and b/web/static/img/auth-hooks/oauth_flow_with_hooks.png differ diff --git a/web/static/img/auth-hooks/signup_flow_with_hooks.png b/web/static/img/auth-hooks/signup_flow_with_hooks.png new file mode 100644 index 0000000000..3c8a5fde2b Binary files /dev/null and b/web/static/img/auth-hooks/signup_flow_with_hooks.png differ