Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to specify OAuth endpoints #1136

Merged
merged 2 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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") ??
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this (and callback_url) be args to this handleOAuth function? I'm not sure they are meant to be something the user should be able to control? and in the example usage thay're appended to the req right before it's passed to this function anyway.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API I'm going for here is that these "handler" functions should always take a NextRequest and return a Promise<NextResponse> in a middleware like fashion. An alternative way to do this is to be more explicit about what parameters we explicitly depend on within the function itself, but I'm worried that it adds boilerplate of getting the data we need from the request. I can sketch that out though and see how that feels in actual use though.

Copy link
Member

@jaclarke jaclarke Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, maybe strip out those two values from the search params here: https://github.com/edgedb/edgedb-js/pull/1136/files#diff-660934b707fc817f8bd5cef3341ffb7f24ffc16f9e200843c762e384e2820c41L173 then? I haven't fully thought out the whole flow and there probably isn't an attack you could do using these params, but just to be safe maybe 😄.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, maybe strip out those two values from the search params here: #1136 (files) then?

Maybe I'm not understanding the suggestion but these parameters will be present in the URL that the browser uses as a link, just like provider, and it needs to be passed all the way to the auth extension. It's not something an end user needs to worry about, but the application developer will absolutely need to set this either in the link button's URL, or manually in their own backend like I did in my example.

I haven't fully thought out the whole flow and there probably isn't an attack you could do using these params, but just to be safe

The URLs are checked against the allowed_redirect_urls config, so if there is an attack vector there, the attack also exists in the existing redirect_to search parameters.

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
Loading