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
JavaScript
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