UNPKG

@auth/core

Version:

Authentication for the Web.

249 lines (248 loc) 10.9 kB
import * as checks from "./checks.js"; import * as o from "oauth4webapi"; import { OAuthCallbackError, OAuthProfileParseError, } from "../../../../errors.js"; import { isOIDCProvider } from "../../../utils/providers.js"; import { conformInternal, customFetch } from "../../../symbols.js"; import { decodeJwt } from "jose"; function formUrlEncode(token) { return encodeURIComponent(token).replace(/%20/g, "+"); } /** * Formats client_id and client_secret as an HTTP Basic Authentication header as per the OAuth 2.0 * specified in RFC6749. */ function clientSecretBasic(clientId, clientSecret) { const username = formUrlEncode(clientId); const password = formUrlEncode(clientSecret); const credentials = btoa(`${username}:${password}`); return `Basic ${credentials}`; } /** * Handles the following OAuth steps. * https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 * https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3 * https://openid.net/specs/openid-connect-core-1_0.html#UserInfoRequest * * @note Although requesting userinfo is not required by the OAuth2.0 spec, * we fetch it anyway. This is because we always want a user profile. */ export async function handleOAuth(params, cookies, options) { const { logger, provider } = options; let as; const { token, userinfo } = provider; // Falls back to authjs.dev if the user only passed params if ((!token?.url || token.url.host === "authjs.dev") && (!userinfo?.url || userinfo.url.host === "authjs.dev")) { // We assume that issuer is always defined as this has been asserted earlier const issuer = new URL(provider.issuer); const discoveryResponse = await o.discoveryRequest(issuer, { [o.allowInsecureRequests]: true, [o.customFetch]: provider[customFetch], }); as = await o.processDiscoveryResponse(issuer, discoveryResponse); if (!as.token_endpoint) throw new TypeError("TODO: Authorization server did not provide a token endpoint."); if (!as.userinfo_endpoint) throw new TypeError("TODO: Authorization server did not provide a userinfo endpoint."); } else { as = { issuer: provider.issuer ?? "https://authjs.dev", // TODO: review fallback issuer token_endpoint: token?.url.toString(), userinfo_endpoint: userinfo?.url.toString(), }; } const client = { client_id: provider.clientId, ...provider.client, }; let clientAuth; switch (client.token_endpoint_auth_method) { // TODO: in the next breaking major version have undefined be `client_secret_post` case undefined: case "client_secret_basic": // TODO: in the next breaking major version use o.ClientSecretBasic() here clientAuth = (_as, _client, _body, headers) => { headers.set("authorization", clientSecretBasic(provider.clientId, provider.clientSecret)); }; break; case "client_secret_post": clientAuth = o.ClientSecretPost(provider.clientSecret); break; case "client_secret_jwt": clientAuth = o.ClientSecretJwt(provider.clientSecret); break; case "private_key_jwt": clientAuth = o.PrivateKeyJwt(provider.token.clientPrivateKey, { // TODO: review in the next breaking change [o.modifyAssertion](_header, payload) { payload.aud = [as.issuer, as.token_endpoint]; }, }); break; case "none": clientAuth = o.None(); break; default: throw new Error("unsupported client authentication method"); } const resCookies = []; const state = await checks.state.use(cookies, resCookies, options); let codeGrantParams; try { codeGrantParams = o.validateAuthResponse(as, client, new URLSearchParams(params), provider.checks.includes("state") ? state : o.skipStateCheck); } catch (err) { if (err instanceof o.AuthorizationResponseError) { const cause = { providerId: provider.id, ...Object.fromEntries(err.cause.entries()), }; logger.debug("OAuthCallbackError", cause); throw new OAuthCallbackError("OAuth Provider returned an error", cause); } throw err; } const codeVerifier = await checks.pkce.use(cookies, resCookies, options); let redirect_uri = provider.callbackUrl; if (!options.isOnRedirectProxy && provider.redirectProxyUrl) { redirect_uri = provider.redirectProxyUrl; } let codeGrantResponse = await o.authorizationCodeGrantRequest(as, client, clientAuth, codeGrantParams, redirect_uri, codeVerifier ?? "decoy", { // TODO: move away from allowing insecure HTTP requests [o.allowInsecureRequests]: true, [o.customFetch]: (...args) => { if (!provider.checks.includes("pkce")) { args[1].body.delete("code_verifier"); } return (provider[customFetch] ?? fetch)(...args); }, }); if (provider.token?.conform) { codeGrantResponse = (await provider.token.conform(codeGrantResponse.clone())) ?? codeGrantResponse; } let profile = {}; const requireIdToken = isOIDCProvider(provider); if (provider[conformInternal]) { switch (provider.id) { case "microsoft-entra-id": case "azure-ad": { /** * These providers return errors in the response body and * need the authorization server metadata to be re-processed * based on the `id_token`'s `tid` claim. * @see: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#error-response-1 */ const responseJson = await codeGrantResponse.clone().json(); if (responseJson.error) { const cause = { providerId: provider.id, ...responseJson, }; throw new OAuthCallbackError(`OAuth Provider returned an error: ${responseJson.error}`, cause); } const { tid } = decodeJwt(responseJson.id_token); if (typeof tid === "string") { const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/; const tenantId = as.issuer?.match(tenantRe)?.[1] ?? "common"; const issuer = new URL(as.issuer.replace(tenantId, tid)); const discoveryResponse = await o.discoveryRequest(issuer, { [o.customFetch]: provider[customFetch], }); as = await o.processDiscoveryResponse(issuer, discoveryResponse); } break; } default: break; } } const processedCodeResponse = await o.processAuthorizationCodeResponse(as, client, codeGrantResponse, { expectedNonce: await checks.nonce.use(cookies, resCookies, options), requireIdToken, }); const tokens = processedCodeResponse; if (requireIdToken) { const idTokenClaims = o.getValidatedIdTokenClaims(processedCodeResponse); profile = idTokenClaims; // Apple sends some of the user information in a `user` parameter as a stringified JSON. // It also only does so the first time the user consents to share their information. if (provider[conformInternal] && provider.id === "apple") { try { profile.user = JSON.parse(params?.user); } catch { } } if (provider.idToken === false) { const userinfoResponse = await o.userInfoRequest(as, client, processedCodeResponse.access_token, { [o.customFetch]: provider[customFetch], // TODO: move away from allowing insecure HTTP requests [o.allowInsecureRequests]: true, }); profile = await o.processUserInfoResponse(as, client, idTokenClaims.sub, userinfoResponse); } } else { if (userinfo?.request) { const _profile = await userinfo.request({ tokens, provider }); if (_profile instanceof Object) profile = _profile; } else if (userinfo?.url) { const userinfoResponse = await o.userInfoRequest(as, client, processedCodeResponse.access_token, { [o.customFetch]: provider[customFetch], // TODO: move away from allowing insecure HTTP requests [o.allowInsecureRequests]: true, }); profile = await userinfoResponse.json(); } else { throw new TypeError("No userinfo endpoint configured"); } } if (tokens.expires_in) { tokens.expires_at = Math.floor(Date.now() / 1000) + Number(tokens.expires_in); } const profileResult = await getUserAndAccount(profile, provider, tokens, logger); return { ...profileResult, profile, cookies: resCookies }; } /** * Returns the user and account that is going to be created in the database. * @internal */ export async function getUserAndAccount(OAuthProfile, provider, tokens, logger) { try { const userFromProfile = await provider.profile(OAuthProfile, tokens); const user = { ...userFromProfile, // The user's id is intentionally not set based on the profile id, as // the user should remain independent of the provider and the profile id // is saved on the Account already, as `providerAccountId`. id: crypto.randomUUID(), email: userFromProfile.email?.toLowerCase(), }; return { user, account: { ...tokens, provider: provider.id, type: provider.type, providerAccountId: userFromProfile.id ?? crypto.randomUUID(), }, }; } catch (e) { // If we didn't get a response either there was a problem with the provider // response *or* the user cancelled the action with the provider. // // Unfortunately, we can't tell which - at least not in a way that works for // all providers, so we return an empty object; the user should then be // redirected back to the sign up page. We log the error to help developers // who might be trying to debug this when configuring a new provider. logger.debug("getProfile error details", OAuthProfile); logger.error(new OAuthProfileParseError(e, { provider: provider.id })); } }