diff --git a/packages/auth-core/src/core.ts b/packages/auth-core/src/core.ts index 15930d428..be164b5fc 100644 --- a/packages/auth-core/src/core.ts +++ b/packages/auth-core/src/core.ts @@ -276,6 +276,75 @@ export class Auth { return this.getToken(code, verifier); } + /** + * When proxying the OAuth flow through your own server, you can use this + * method to get the URL to redirect to that will include the required + * parameters. Will throw an error if the auth server returns an error, or if + * the response does not contain a location header. + * + * @param {URLSearchParams} searchParams From the original request. Gets + * passed on to the auth server + * @returns {string} The URL to redirect to + */ + async handleOAuthAuthorize(searchParams: URLSearchParams): Promise { + const serverUrl = new URL("authorize", this.baseUrl); + searchParams.forEach((value, key) => { + serverUrl.searchParams.append(key, value); + }); + + const response = await fetch(serverUrl, { + redirect: "manual", + }); + + if (response.status > 399) { + throw new Error( + `OAuth authorization failed with status ${response.status}: ${await response.text()}`, + ); + } + + const location = response.headers.get("location"); + + if (location == null) { + throw new Error("OAuth authorization failed: no location header"); + } + + return location; + } + + /** + * When proxying the OAuth flow through your own server, you can use this + * method to complete the flow, and get the URL to redirect to with the + * correct parameters. Will throw an error if the auth server returns an + * error, or if the response does not contain a location header. + * + * @param {URLSearchParams} searchParams From the original request. Gets + * passed on to the auth server + * @returns {string} The URL to redirect to + */ + async handleOAuthCallback(searchParams: URLSearchParams): Promise { + const serverUrl = new URL("callback", this.baseUrl); + searchParams.forEach((value, key) => { + serverUrl.searchParams.append(key, value); + }); + + const response = await fetch(serverUrl, { + redirect: "manual", + }); + + if (response.status > 399) { + throw new Error( + `OAuth callback failed with status ${response.status}: ${await response.text()}`, + ); + } + + const location = response.headers.get("location"); + if (location == null) { + throw new Error("OAuth callback failed: no location header"); + } + + return location; + } + async getProvidersInfo() { // TODO: cache this data when we have a way to invalidate on config update try { @@ -313,18 +382,49 @@ export class AuthPCKESession { providerName: BuiltinOAuthProviderNames, redirectTo: string, redirectToOnSignup?: string, - ) { - const url = new URL("authorize", this.auth.baseUrl); + ): string { + return this.addOAuthParamsToUrl(new URL("authorize", this.auth.baseUrl), { + providerName, + redirectTo, + redirectToOnSignup, + }).toString(); + } - url.searchParams.set("provider", providerName); - url.searchParams.set("challenge", this.challenge); - url.searchParams.set("redirect_to", redirectTo); + /** + * Build a URL with the required OAuth parameters used to call the OAuth + * authorize endpoint. + * + * @param {URL | string} url If you pass a URL object, it will be mutated + * and returned. If you pass a string, a new URL object will be created. + * @param {Object} oauthParams + * @returns {URL} + */ + addOAuthParamsToUrl( + url: string | URL, + oauthParams: { + providerName: string; + redirectTo: string; + redirectToOnSignup?: string; + callbackUrl?: string; + }, + ): URL { + const withParams = typeof url === "string" ? new URL(url) : url; + withParams.searchParams.set("provider", oauthParams.providerName); + withParams.searchParams.set("challenge", this.challenge); + withParams.searchParams.set("redirect_to", oauthParams.redirectTo); + + if (oauthParams.redirectToOnSignup) { + withParams.searchParams.set( + "redirect_to_on_signup", + oauthParams.redirectToOnSignup, + ); + } - if (redirectToOnSignup) { - url.searchParams.set("redirect_to_on_signup", redirectToOnSignup); + if (oauthParams.callbackUrl) { + withParams.searchParams.set("callback_url", oauthParams.callbackUrl); } - return url.toString(); + return withParams; } // getEmailPasswordSigninFormActionUrl( diff --git a/packages/auth-nextjs/src/shared.ts b/packages/auth-nextjs/src/shared.ts index 63dd24b30..3031a3b72 100644 --- a/packages/auth-nextjs/src/shared.ts +++ b/packages/auth-nextjs/src/shared.ts @@ -1,7 +1,6 @@ import { type Client } from "edgedb"; import { Auth, - builtinOAuthProviderNames, type BuiltinOAuthProviderNames, type TokenData, ConfigurationError, @@ -20,7 +19,7 @@ import { import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import type { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; export { type BuiltinProviderNames, NextAuthHelpers, type NextAuthOptions }; @@ -138,6 +137,107 @@ export abstract class NextAuth extends NextAuthHelpers { }); } + public oAuth = { + /** + * Start the OAuth flow by getting the OAuth configuration from the request + * URL search parameters and redirecting to the auth extension server's + * authorize endpoint. + * + * @param {NextRequest} req The incoming request. + * @param {string=} req.nextUrl.searchParams.provider_name The name of the + * OAuth provider to use. + * @param {string=} req.nextUrl.searchParams.authorize_url The URL to + * redirect to to start the OAuth flow. If not provided, will default to + * the auth extension server's authorize endpoint. + * @param {string=} req.nextUrl.searchParams.callback_url The URL to + * redirect to within the OAuth flow once the user has authorized the OAuth + * client. + */ + handleOAuth: async (req: NextRequest): Promise => { + const providerName = req.nextUrl.searchParams.get("provider_name"); + if (!providerName) { + throw new InvalidDataError("Missing provider_name in request"); + } + + const callbackUrl = req.nextUrl.searchParams.get("callback_url"); + + const authBasePath = this._authRoute.endsWith("/") + ? this._authRoute + : `${this._authRoute}/`; + const redirectTo = new URL("oauth/callback", authBasePath); + const redirectToOnSignup = new URL(redirectTo); + redirectToOnSignup.searchParams.set("isSignUp", "true"); + + const authorizeUrl = + req.nextUrl.searchParams.get("authorize_url") ?? + new URL("authorize", this.options.baseUrl).toString(); + + const pkceSession = await (await this.core).createPKCESession(); + this.setVerifierCookie(pkceSession.verifier); + + const location = pkceSession.addOAuthParamsToUrl(authorizeUrl, { + providerName, + redirectTo: redirectTo.toString(), + redirectToOnSignup: redirectToOnSignup.toString(), + ...(callbackUrl ? { callbackUrl } : {}), + }); + console.log(`Redirecting to ${location}`); + return NextResponse.redirect(location); + }, + + /** + * When implementing your own OAuth flow, you should call this method in + * your OAuth authorize route. It will call the auth extension server's + * authorize endpoint, copying the relevant search parameters from the + * incoming request to the auth extension server's authorize endpoint. + * + * You would pass the URL to the endpoint that calls this method as the + * `authorize_url` parameter in the call to the endpoint that calls the + * `handleOAuth` method. + * + * @param {NextRequest} req The incoming request. + * @param {string} req.nextUrl.searchParams.provider_name The name of the + * OAuth provider to use. + * @param {string=} req.nextUrl.searchParams.callback_url The URL to + * redirect to within the OAuth flow once the user has authorized the OAuth + * client. + * @param {string} req.nextUrl.searchParams.redirect_to The URL to + * redirect to after the OAuth flow is complete. + * @param {string=} req.nextUrl.searchParams.redirect_to_on_signup The URL + * to redirect to after the OAuth flow is complete if the user is signing + * up. + */ + handleAuthorize: async (req: NextRequest): Promise => { + const location = await ( + await this.core + ).handleOAuthAuthorize(req.nextUrl.searchParams); + + return NextResponse.redirect(location); + }, + + /** + * When implementing your own OAuth flow, you should call this method in + * your OAuth callback route, which will handle the callback from the + * Identity Provider containing the authorization code. This method will + * then call the auth extension server's callback endpoint, passing along + * the relevant search parameters from the incoming request. + * + * You would pass the URL to the endpoint that calls this method as the + * `callback_url` parameter in th ecall to th eendpoint that calls the + * `handleOAuth` method. + * + * @param {NextRequest} req + * @returns + */ + handleCallback: async (req: NextRequest): Promise => { + const location = await ( + await this.core + ).handleOAuthCallback(req.nextUrl.searchParams); + + return NextResponse.redirect(location); + }, + }; + createAuthRouteHandlers({ onOAuthCallback, onEmailPasswordSignIn, @@ -162,24 +262,7 @@ export abstract class NextAuth extends NextAuthHelpers { `'onOAuthCallback' auth route handler not configured`, ); } - const provider = req.nextUrl.searchParams.get( - "provider_name", - ) as BuiltinOAuthProviderNames | null; - if (!provider || !builtinOAuthProviderNames.includes(provider)) { - throw new InvalidDataError(`invalid provider_name: ${provider}`); - } - const redirectUrl = `${this._authRoute}/oauth/callback`; - const pkceSession = await this.core.then((core) => - core.createPKCESession(), - ); - this.setVerifierCookie(pkceSession.verifier); - return redirect( - pkceSession.getOAuthUrl( - provider, - redirectUrl, - `${redirectUrl}?isSignUp=true`, - ), - ); + return this.oAuth.handleOAuth(req); } case "oauth/callback": { if (!onOAuthCallback) {