Skip to content

Commit

Permalink
Add ability to specify OAuth endpoints (#1136)
Browse files Browse the repository at this point in the history
If your database is deployed to a private network and is not available
at a public URL, this adds the ability to drive the whole flow from your
application server, delegating to the auth server from your application
server.
  • Loading branch information
scotttrinh authored Dec 4, 2024
1 parent 807e866 commit 8a28c30
Show file tree
Hide file tree
Showing 2 changed files with 210 additions and 28 deletions.
116 changes: 108 additions & 8 deletions packages/auth-core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
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 {
Expand Down Expand Up @@ -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(
Expand Down
122 changes: 102 additions & 20 deletions packages/auth-nextjs/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type Client } from "edgedb";
import {
Auth,
builtinOAuthProviderNames,
type BuiltinOAuthProviderNames,
type TokenData,
ConfigurationError,
Expand All @@ -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 };

Expand Down Expand Up @@ -145,6 +144,106 @@ 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<NextResponse> => {
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();
await this.setVerifierCookie(pkceSession.verifier);

const location = pkceSession.addOAuthParamsToUrl(authorizeUrl, {
providerName,
redirectTo: redirectTo.toString(),
redirectToOnSignup: redirectToOnSignup.toString(),
...(callbackUrl ? { callbackUrl } : {}),
});
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<NextResponse> => {
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<NextResponse> => {
const location = await (
await this.core
).handleOAuthCallback(req.nextUrl.searchParams);

return NextResponse.redirect(location);
},
};

createAuthRouteHandlers({
onOAuthCallback,
onEmailPasswordSignIn,
Expand All @@ -170,24 +269,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(),
);
await this.setVerifierCookie(pkceSession.verifier);
return redirect(
pkceSession.getOAuthUrl(
provider,
redirectUrl,
`${redirectUrl}?isSignUp=true`,
),
);
return this.oAuth.handleOAuth(req);
}
case "oauth/callback": {
if (!onOAuthCallback) {
Expand Down

0 comments on commit 8a28c30

Please sign in to comment.