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

feat(core): make next-auth optionally opt-into Web-compatible mode #4299

Closed
wants to merge 13 commits into from
2 changes: 1 addition & 1 deletion packages/next-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@
"dependencies": {
"@babel/runtime": "^7.16.3",
"@panva/hkdf": "^1.0.1",
"@panva/oauth4webapi": "^0.0.10",
"cookie": "^0.4.1",
"jose": "^4.3.7",
"oauth": "^0.9.15",
"openid-client": "^5.1.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
Expand Down
32 changes: 32 additions & 0 deletions packages/next-auth/src/core/lib/oauth/authorization-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { discoveryRequest, processDiscoveryResponse } from "@panva/oauth4webapi"

import type { AuthorizationServer } from "@panva/oauth4webapi"
import type { InternalProvider } from "src/lib/types"

export default async function getAuthorizationServer(
provider: InternalProvider<"oauth">
): Promise<AuthorizationServer> {
if (provider.idToken) {
const issuer = new URL(provider.issuer as string)
return await discoveryRequest(issuer).then(
async (response) => await processDiscoveryResponse(issuer, response)
)
} else {
return {
issuer: provider.issuer as string,
authorization_endpoint:
typeof provider.authorization === "string"
? provider.authorization
: provider.authorization?.url,
token_endpoint:
typeof provider.token === "string"
? provider.token
: provider.token?.url,
userinfo_endpoint:
typeof provider.userinfo === "string"
? provider.userinfo
: provider.userinfo?.url,
jwks_uri: provider.jwks_uri,
}
}
}
57 changes: 41 additions & 16 deletions packages/next-auth/src/core/lib/oauth/authorization-url.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { createState } from "./state-handler"
import { createPKCE } from "./pkce-handler"
import getAuthorizationServer from "./authorization-server"

import type { AuthorizationParameters } from "openid-client"
import type { InternalOptions } from "../../types"
Expand Down Expand Up @@ -50,28 +50,53 @@ export default async function getAuthorizationUrl(params: {
return { redirect: url }
}

const client = await openidClient(options)
const authorizationServer = await getAuthorizationServer(provider)

const authorizationParams: AuthorizationParameters = params
const cookies: Cookie[] = []
if (!authorizationServer.authorization_endpoint) throw new Error()
const authorizationUrl = new URL(authorizationServer.authorization_endpoint)

const state = await createState(options)
if (state) {
authorizationParams.state = state.value
cookies.push(state.cookie)
for (const [key, value] of Object.entries(params)) {
authorizationUrl.searchParams.set(key, value as string)
}

const pkce = await createPKCE(options)
if (pkce) {
authorizationParams.code_challenge = pkce.code_challenge
authorizationParams.code_challenge_method = pkce.code_challenge_method
cookies.push(pkce.cookie)
authorizationUrl.searchParams.set("client_id", provider.clientId as string)
authorizationUrl.searchParams.set("redirect_uri", provider.callbackUrl)

if (typeof provider.authorization !== "string" && provider.authorization) {
const { params: authorizationEndpointParams } = provider.authorization

if (typeof authorizationEndpointParams?.response_type === "string") {
authorizationUrl.searchParams.set(
"response_type",
authorizationEndpointParams.response_type
)
}
if (typeof authorizationEndpointParams?.scope === "string") {
authorizationUrl.searchParams.set(
"scope",
authorizationEndpointParams.scope
)
}
}

const url = client.authorizationUrl(authorizationParams)
const cookies: Cookie[] = []

const pkce = await createPKCE(options, authorizationServer)
authorizationUrl.searchParams.set("code_challenge", pkce.code_challenge)
authorizationUrl.searchParams.set(
"code_challenge_method",
pkce.code_challenge_method
)
cookies.push(pkce.cookie)

const state = await createState(options)
if (!pkce.isSupported && state) {
authorizationUrl.searchParams.set("state", state.value)
cookies.push(state.cookie)
}

logger.debug("GET_AUTHORIZATION_URL", { url, cookies })
return { redirect: url, cookies }
logger.debug("GET_AUTHORIZATION_URL", { authorizationUrl, cookies })
return { redirect: authorizationUrl.href, cookies }
} catch (error) {
logger.error("GET_AUTHORIZATION_URL_ERROR", error as Error)
throw error
Expand Down
135 changes: 90 additions & 45 deletions packages/next-auth/src/core/lib/oauth/callback.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { TokenSet } from "openid-client"
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { useState } from "./state-handler"
import { usePKCECodeVerifier } from "./pkce-handler"
import { OAuthCallbackError } from "../../errors"
import {
authorizationCodeGrantRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOAuth2Response,
processAuthorizationCodeOpenIDResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "@panva/oauth4webapi"
import getAuthorizationServer from "./authorization-server"

import type { CallbackParamsType } from "openid-client"
import type { Account, LoggerInstance, Profile } from "../../.."
import type { OAuthChecks, OAuthConfig } from "../../../providers"
import type { InternalOptions } from "../../types"
import type { RequestInternal, OutgoingResponse } from "../.."
import type { Cookie } from "../cookie"
import type {
OAuth2Error,
OAuth2TokenEndpointResponse,
OpenIDTokenEndpointResponse,
} from "@panva/oauth4webapi"

export default async function oAuthCallback(params: {
options: InternalOptions<"oauth">
Expand All @@ -19,7 +34,7 @@ export default async function oAuthCallback(params: {
method: Required<RequestInternal>["method"]
cookies: RequestInternal["cookies"]
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
const { options, query, body, method, cookies } = params
const { options, query, body, cookies } = params
const { logger, provider } = options

const errorMessage = body?.error ?? query?.error
Expand Down Expand Up @@ -65,81 +80,111 @@ export default async function oAuthCallback(params: {
}

try {
const client = await openidClient(options)
const client = openidClient(provider)
const authorizationServer = await getAuthorizationServer(provider)

let tokens: TokenSet
let tokens:
| OpenIDTokenEndpointResponse
| OAuth2TokenEndpointResponse
| OAuth2Error

const checks: OAuthChecks = {}
let expectedState: string | typeof expectNoState = expectNoState
const resCookies: Cookie[] = []
const authParams = new URLSearchParams(query)

const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name]
const pkce = await usePKCECodeVerifier(
codeVerifier,
options,
authorizationServer
)
resCookies.push(pkce.cookie)

const state = await useState(cookies?.[options.cookies.state.name], options)

if (state) {
checks.state = state.value
if (!pkce.isSupported && state) {
resCookies.push(state.cookie)
expectedState = state.value
authParams.append("state", state.value)
}

const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name]
const pkce = await usePKCECodeVerifier(codeVerifier, options)
if (pkce) {
checks.code_verifier = pkce.codeVerifier
resCookies.push(pkce.cookie)
const callbackParameters = validateAuthResponse(
authorizationServer,
client,
authParams,
expectedState
)
if (isOAuth2Error(callbackParameters)) {
throw new OAuthCallbackError(callbackParameters.error)
}

const params: CallbackParamsType = {
...client.callbackParams({
url: `http://n?${new URLSearchParams(query)}`,
// TODO: Ask to allow object to be passed upstream:
// https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439
// @ts-expect-error
body,
method,
}),
// @ts-expect-error
...provider.token?.params,
}
const response = await authorizationCodeGrantRequest(
authorizationServer,
client,
callbackParameters,
provider.callbackUrl,
pkce.codeVerifier
)

// @ts-expect-error
if (provider.token?.request) {
// @ts-expect-error
if (typeof provider.token !== "string" && provider.token?.request) {
const params = {
...callbackParameters,
...provider.token?.params,
}
const checks = new URLSearchParams()
if (state) checks.append("state", state.value)
checks.append("code_verifier", pkce.codeVerifier)
const response = await provider.token.request({
provider,
params,
checks,
client,
})
tokens = new TokenSet(response.tokens)
tokens = response.tokens
} else if (provider.idToken) {
tokens = await client.callback(provider.callbackUrl, params, checks)
tokens = await processAuthorizationCodeOpenIDResponse(
authorizationServer,
client,
response
)
} else {
tokens = await client.oauthCallback(provider.callbackUrl, params, checks)
tokens = await processAuthorizationCodeOAuth2Response(
authorizationServer,
client,
response
)
}

// REVIEW: How can scope be returned as an array?
if (Array.isArray(tokens.scope)) {
tokens.scope = tokens.scope.join(" ")
if (isOAuth2Error(tokens)) {
throw new OAuthCallbackError(tokens.error)
}

let profile: Profile
// @ts-expect-error
if (provider.userinfo?.request) {
// @ts-expect-error
let profile: Profile | Response
if (typeof provider.userinfo !== "string" && provider.userinfo?.request) {
profile = await provider.userinfo.request({
provider,
tokens,
client,
})
} else if (provider.idToken) {
profile = tokens.claims()
const idToken = getValidatedIdTokenClaims(tokens)

profile = await processUserInfoResponse(
authorizationServer,
client,
idToken?.sub as string,
response
)
} else {
profile = await client.userinfo(tokens, {
// @ts-expect-error
params: provider.userinfo?.params,
})
profile = await userInfoRequest(
authorizationServer,
client,
tokens.access_token
)
}

const profileResult = await getProfile({
profile,
profile: profile as Profile,
provider,
tokens,
logger,
Expand All @@ -156,7 +201,7 @@ export default async function oAuthCallback(params: {

export interface GetProfileParams {
profile: Profile
tokens: TokenSet
tokens: OpenIDTokenEndpointResponse | OAuth2TokenEndpointResponse
provider: OAuthConfig<any>
logger: LoggerInstance
}
Expand Down
31 changes: 10 additions & 21 deletions packages/next-auth/src/core/lib/oauth/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { InternalProvider } from "src/lib/types"
import type { Client as WebApiClient } from "@panva/oauth4webapi"
import { Issuer, custom } from "openid-client"
import type { Client } from "openid-client"
import type { InternalOptions } from "../../types"

/**
* NOTE: We can add auto discovery of the provider's endpoint
* that requires only one endpoint to be specified by the user.
* Check out `Issuer.discover`
*
* Client supporting OAuth 2.x and OIDC
*/
export function webApiClient(provider: InternalProvider<"oauth">): WebApiClient {
return {
client_id: provider.clientId as string,
client_secret: provider.clientSecret as string,
token_endpoint_auth_method: "client_secret_basic",
...provider.client,
}
}
export async function openidClient(
options: InternalOptions<"oauth">
): Promise<Client> {
Expand All @@ -31,21 +37,4 @@ export async function openidClient(
userinfo_endpoint: provider.userinfo?.url ?? provider.userinfo,
})
}

const client = new issuer.Client(
{
client_id: provider.clientId as string,
client_secret: provider.clientSecret as string,
redirect_uris: [provider.callbackUrl],
...provider.client,
},
provider.jwks
)

// allow a 10 second skew
// See https://github.com/nextauthjs/next-auth/issues/3032
// and https://github.com/nextauthjs/next-auth/issues/3067
client[custom.clock_tolerance] = 10

return client
}
9 changes: 9 additions & 0 deletions packages/next-auth/src/core/lib/oauth/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { generateRandomCodeVerifier } from "@panva/oauth4webapi"

/**
* Generate random `state` value encoded as base64url. This method returns oauth4webapi's `generateRandomCodeVerifier` for convenience.
* @see {@link https://github.com/panva/oauth4webapi/blob/main/docs/functions/generateRandomCodeVerifier.md generateRandomCodeVerifier.}
*/
export function generateRandomState() {
return generateRandomCodeVerifier()
}
Loading