UNPKG

remix-auth-oauth2

Version:

A strategy to use and implement OAuth2 framework for authentication with federated services like Google, Facebook, GitHub, etc.

191 lines 8.46 kB
import { ObjectParser } from "@edgefirst-dev/data/parser"; import {} from "@mjackson/headers"; import { CodeChallengeMethod, OAuth2Client, OAuth2RequestError, UnexpectedErrorResponseBodyError, UnexpectedResponseError, generateCodeVerifier, generateState, } from "arctic"; import { Strategy } from "remix-auth/strategy"; import { redirect } from "./lib/redirect.js"; import { StateStore } from "./lib/store.js"; const WELL_KNOWN = ".well-known/openid-configuration"; export { OAuth2RequestError, CodeChallengeMethod, UnexpectedResponseError, UnexpectedErrorResponseBodyError, }; export class OAuth2Strategy extends Strategy { options; name = "oauth2"; client; constructor(options, verify) { super(verify); this.options = options; this.client = new OAuth2Client(options.clientId, options.clientSecret, options.redirectURI?.toString() ?? null); } get cookieName() { if (typeof this.options.cookie === "string") { return this.options.cookie || "oauth2"; } return this.options.cookie?.name ?? "oauth2"; } get cookieOptions() { if (typeof this.options.cookie !== "object") return {}; return this.options.cookie ?? {}; } async authenticate(request) { let url = new URL(request.url); let stateUrl = url.searchParams.get("state"); if (!stateUrl) { let { state, codeVerifier, url } = this.createAuthorizationURL(); if (this.options.audience) { if (Array.isArray(this.options.audience)) { for (let audience of this.options.audience) { url.searchParams.append("audience", audience); } } else url.searchParams.append("audience", this.options.audience); } url.search = this.authorizationParams(url.searchParams, request).toString(); let store = StateStore.fromRequest(request, this.cookieName); store.set(state, codeVerifier); throw redirect(url.toString(), { headers: { "Set-Cookie": store .toSetCookie(this.cookieName, this.cookieOptions) .toString(), }, }); } let store = StateStore.fromRequest(request, this.cookieName); if (!store.has()) { throw new ReferenceError("Missing state on cookie."); } if (!store.has(stateUrl)) { throw new RangeError("State in URL doesn't match state in cookie."); } let error = url.searchParams.get("error"); if (error) { let description = url.searchParams.get("error_description"); let uri = url.searchParams.get("error_uri"); throw new OAuth2RequestError(error, description, uri, stateUrl); } let code = url.searchParams.get("code"); if (!code) throw new ReferenceError("Missing code in the URL"); let codeVerifier = store.get(stateUrl); if (!codeVerifier) { throw new ReferenceError("Missing code verifier on cookie."); } let tokens = await this.validateAuthorizationCode(code, codeVerifier); let user = await this.verify({ request, tokens }); return user; } createAuthorizationURL() { let state = generateState(); let codeVerifier = generateCodeVerifier(); let url = this.client.createAuthorizationURLWithPKCE(this.options.authorizationEndpoint.toString(), state, this.options.codeChallengeMethod ?? CodeChallengeMethod.S256, codeVerifier, this.options.scopes ?? []); return { state, codeVerifier, url }; } validateAuthorizationCode(code, codeVerifier) { return this.client.validateAuthorizationCode(this.options.tokenEndpoint.toString(), code, codeVerifier); } /** * Return extra parameters to be included in the authorization request. * * Some OAuth 2.0 providers allow additional, non-standard parameters to be * included when requesting authorization. Since these parameters are not * standardized by the OAuth 2.0 specification, OAuth 2.0-based authentication * strategies can override this function in order to populate these * parameters as required by the provider. */ authorizationParams(params, request) { return new URLSearchParams(params); } /** * Get a new OAuth2 Tokens object using the refresh token once the previous * access token has expired. * @param refreshToken The refresh token to use to get a new access token * @returns The new OAuth2 tokens object * @example * ```ts * let tokens = await strategy.refreshToken(refreshToken); * console.log(tokens.accessToken()); * ``` */ refreshToken(refreshToken) { return this.client.refreshAccessToken(this.options.tokenEndpoint.toString(), refreshToken, this.options.scopes ?? []); } /** * Users the token revocation endpoint of the identity provider to revoke the * access token and make it invalid. * * @param token The access token to revoke * @example * ```ts * // Get it from where you stored it * let accessToken = await getAccessToken(); * await strategy.revokeToken(tokens.access_token); * ``` */ revokeToken(token) { let endpoint = this.options.tokenRevocationEndpoint; if (!endpoint) throw new Error("Token revocation endpoint is not set."); return this.client.revokeToken(endpoint.toString(), token); } /** * Discover the OAuth2 issuer and create a new OAuth2Strategy instance from * the OIDC configuration that is returned. * * This method will fetch the OIDC configuration from the issuer and create a * new OAuth2Strategy instance with the provided options and verify function. * * @param uri The URI of the issuer, this can be a full URL or just the domain * @param options The rest of the options to pass to the OAuth2Strategy constructor, clientId, clientSecret, redirectURI, and scopes are required. * @param verify The verify function to use with the OAuth2Strategy instance * @returns A new OAuth2Strategy instance * @example * ```ts * let strategy = await OAuth2Strategy.discover( * "https://accounts.google.com", * { * clientId: "your-client-id", * clientSecret: "your-client-secret", * redirectURI: "https://your-app.com/auth/callback", * scopes: ["openid", "email", "profile"], * }, * async ({ tokens }) => { * return getUserProfile(tokens.access_token); * }, * ); */ static async discover(uri, options, verify) { // Parse the URI into a URL object let url = new URL(uri); if (!url.pathname.includes("well-known")) { // Add the well-known path to the URL if it's not already there url.pathname = url.pathname.endsWith("/") ? `${url.pathname}${WELL_KNOWN}` : `${url.pathname}/${WELL_KNOWN}`; } // Fetch the metadata from the issuer and validate it let response = await fetch(url, { headers: { Accept: "application/json" }, }); // If the response is not OK, throw an error if (!response.ok) throw new Error(`Failed to discover issuer at ${url}`); // Parse the response body let parser = new ObjectParser(await response.json()); // biome-ignore lint/complexity/noThisInStatic: This is need for subclasses return new this({ authorizationEndpoint: new URL(parser.string("authorization_endpoint")), tokenEndpoint: new URL(parser.string("token_endpoint")), tokenRevocationEndpoint: parser.has("revocation_endpoint") ? new URL(parser.string("revocation_endpoint")) : undefined, codeChallengeMethod: parser.has("code_challenge_methods_supported") ? parser.array("code_challenge_methods_supported").includes("S256") ? CodeChallengeMethod.S256 : CodeChallengeMethod.Plain : undefined, ...options, }, verify); } } //# sourceMappingURL=index.js.map