Compare commits

...

13 Commits

Author SHA1 Message Date
Thang Vu
f48e0f1e2d Update pnpm-lock.yaml 2022-06-20 14:08:39 +07:00
Thang Vu
2eef2f85d5 Merge branch 'main' into feat/oauth4webapi 2022-06-20 14:04:22 +07:00
Thang Vu
bf31512071 Merge branch 'main' into feat/oauth4webapi 2022-06-20 12:00:44 +07:00
Thang Vu
acc9966285 Update pnpm-lock.yaml 2022-06-10 16:02:27 +07:00
Thang Vu
6dd44000d7 Merge branch 'main' into feat/oauth4webapi 2022-06-10 15:55:26 +07:00
Thang Vu
2267292f5f Improve pkce 2022-04-08 09:13:38 +07:00
Thang Vu
883b36e2b2 check if PKCE supported 2022-04-07 20:40:00 +07:00
Thang Vu
029edb93ee Always use PKCE 2022-04-07 18:46:49 +07:00
Thang Vu
ebcc3280df Some improvement
- handle pkce discovery
- update `validateAuthResponse` to use `expectState`
2022-04-07 15:04:41 +07:00
Thang Vu
70a16e82c3 Throw better error 2022-04-06 17:36:43 +07:00
Thang Vu
1365211a4e Check state only if pkce is not suported 2022-04-06 17:33:54 +07:00
Thang Vu
6df5773220 Revert most breaking changes.
test
2022-04-06 16:31:26 +07:00
Thang Vu
e3e3cf12d1 replace openid-client with oauth4webapi 2022-04-04 15:15:23 +07:00
13 changed files with 5633 additions and 7069 deletions

View File

@@ -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"

View File

@@ -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,
}
}
}

View File

@@ -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"
@@ -50,28 +50,53 @@ export default async function getAuthorizationUrl(params: {
return { redirect: url }
}
const client = await openidClient(options)
const authorizationServer = await getAuthorizationServer(provider)
if (!authorizationServer.authorization_endpoint) throw new Error()
const authorizationUrl = new URL(authorizationServer.authorization_endpoint)
for (const [key, value] of Object.entries(params)) {
authorizationUrl.searchParams.set(key, value as string)
}
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 authorizationParams: AuthorizationParameters = params
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 (state) {
authorizationParams.state = state.value
if (!pkce.isSupported && state) {
authorizationUrl.searchParams.set("state", state.value)
cookies.push(state.cookie)
}
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)
}
const url = client.authorizationUrl(authorizationParams)
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

View File

@@ -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">
@@ -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
@@ -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,
@@ -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
}

View File

@@ -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> {
@@ -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
}

View File

@@ -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()
}

View File

@@ -1,30 +1,43 @@
import * as jwt from "../../../jwt"
import {
generateRandomCodeVerifier,
calculatePKCECodeChallenge,
} from "@panva/oauth4webapi"
import { generators } from "openid-client"
import type { InternalOptions } from "../../types"
import type { Cookie } from "../cookie"
import type { AuthorizationServer } from "@panva/oauth4webapi"
const PKCE_CODE_CHALLENGE_METHOD = "S256"
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
/**
* Check if PKCE is supported by the authorization server.
* @see {@link https://datatracker.ietf.org/doc/html/rfc8414#section-2 code_challenge_methods_supported}
* @param as The authorization server. @see {@link https://github.com/panva/oauth4webapi/blob/main/docs/interfaces/AuthorizationServer.md AuthorizationServer}
*/
function isPKCESupported(as: AuthorizationServer) {
return !!as.code_challenge_methods_supported?.includes(
PKCE_CODE_CHALLENGE_METHOD
)
}
/**
* Returns `code_challenge` and `code_challenge_method`
* and saves them in a cookie.
*/
export async function createPKCE(options: InternalOptions<"oauth">): Promise<
| undefined
| {
code_challenge: string
code_challenge_method: "S256"
cookie: Cookie
}
> {
const { cookies, logger, provider } = options
if (!provider.checks?.includes("pkce")) {
// Provider does not support PKCE, return nothing.
return
}
const code_verifier = generators.codeVerifier()
const code_challenge = generators.codeChallenge(code_verifier)
export async function createPKCE(
options: InternalOptions<"oauth">,
as: AuthorizationServer
): Promise<{
code_challenge: string
code_challenge_method: "S256"
cookie: Cookie
isSupported: boolean
}> {
const { cookies, logger } = options
const code_verifier = generateRandomCodeVerifier()
const code_challenge = await calculatePKCECodeChallenge(code_verifier)
const expires = new Date()
expires.setTime(expires.getTime() + PKCE_MAX_AGE * 1000)
@@ -51,34 +64,37 @@ export async function createPKCE(options: InternalOptions<"oauth">): Promise<
value: encryptedCodeVerifier,
options: { ...cookies.pkceCodeVerifier.options, expires },
},
isSupported: isPKCESupported(as),
}
}
/**
* Returns code_verifier if provider uses PKCE,
* Returns `code_verifier`,
* and clears the container cookie afterwards.
*/
export async function usePKCECodeVerifier(
codeVerifier: string | undefined,
options: InternalOptions<"oauth">
): Promise<{ codeVerifier: string; cookie: Cookie } | undefined> {
const { cookies, provider } = options
options: InternalOptions<"oauth">,
as: AuthorizationServer
): Promise<{ codeVerifier: string; cookie: Cookie; isSupported: boolean }> {
if (codeVerifier === undefined) throw new Error("Invalid code verifier")
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
return
}
const { cookies } = options
const pkce = (await jwt.decode({
const pkce = await jwt.decode({
...options.jwt,
token: codeVerifier,
})) as any
})
if (pkce === null) throw new Error("Invalid code verifier")
return {
codeVerifier: pkce?.code_verifier ?? undefined,
codeVerifier: pkce.code_verifier as string,
cookie: {
name: cookies.pkceCodeVerifier.name,
value: "",
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
},
isSupported: isPKCESupported(as),
}
}

View File

@@ -2,6 +2,7 @@ import { generators } from "openid-client"
import type { InternalOptions } from "../../types"
import type { Cookie } from "../cookie"
import { generateRandomState } from "./helper"
const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
@@ -16,7 +17,7 @@ export async function createState(
return
}
const state = generators.state()
const state = generateRandomState()
const encodedState = await jwt.encode({
...jwt,

View File

@@ -11,6 +11,7 @@ import type { TokenSetParameters } from "openid-client"
import type { JWT, JWTOptions } from "../jwt"
import type { LoggerInstance } from "../utils/logger"
import type { CookieSerializeOptions } from "cookie"
import type { TokenEndpointResponse } from "@panva/oauth4webapi"
import type { NextApiRequest, NextApiResponse } from "next"
@@ -224,7 +225,7 @@ export interface Theme {
* Some of them are available with different casing,
* but they refer to the same value.
*/
export type TokenSet = TokenSetParameters
export type TokenSet = TokenEndpointResponse
/**
* Usually contains information about the provider being used

View File

@@ -1,3 +1,4 @@
import { AuthorizationServer, userInfoRequest } from "@panva/oauth4webapi"
import type { OAuthConfig, OAuthUserConfig } from "."
interface FacebookPictureData {
@@ -26,11 +27,17 @@ export default function Facebook<P extends FacebookProfile>(
// https://developers.facebook.com/docs/graph-api/reference/user/#fields
params: { fields: "id,name,email,picture" },
async request({ tokens, client, provider }) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return await client.userinfo(tokens.access_token!, {
// @ts-expect-error
params: provider.userinfo?.params,
// @ts-expect-error
const userinfo_endpoint = new URL(provider.userinfo?.url)
// @ts-expect-error
Object.entries(provider.userinfo?.params).forEach(([key, value]) => {
userinfo_endpoint.searchParams.append(key, value as string)
})
const as: AuthorizationServer = {
issuer: options.issuer as string,
userinfo_endpoint: userinfo_endpoint.href,
}
return await userInfoRequest(as, client, tokens.access_token)
},
},
profile(profile: P) {

View File

@@ -1,33 +1,20 @@
import type { CommonProviderOptions } from "../providers"
import type { Profile, TokenSet, User, Awaitable } from ".."
import type {
AuthorizationParameters,
CallbackParamsType,
Issuer,
ClientMetadata,
IssuerMetadata,
OAuthCallbackChecks,
OpenIDCallbackChecks,
HttpOptions,
} from "openid-client"
import type { JWK } from "jose"
type Client = InstanceType<Issuer["Client"]>
import type { AuthorizationServer, Client } from "@panva/oauth4webapi"
export type { OAuthProviderType } from "./oauth-types"
type ChecksType = "pkce" | "state" | "none"
export type OAuthChecks = OpenIDCallbackChecks | OAuthCallbackChecks
type PartialIssuer = Partial<Pick<IssuerMetadata, "jwks_endpoint" | "issuer">>
type PartialIssuer = Partial<Pick<AuthorizationServer, "jwks_uri" | "issuer">>
type UrlParams = Record<string, unknown>
type EndpointRequest<C, R, P> = (
context: C & {
/** `openid-client` Client */
/** `oauth4webapi` Client */
client: Client
/** Provider is passed for convenience, ans also contains the `callbackUrl`. */
provider: OAuthConfig<P> & {
@@ -61,8 +48,7 @@ export type EndpointHandler<
R = any
> = AdvancedEndpointHandler<P, C, R>
export type AuthorizationEndpointHandler =
EndpointHandler<AuthorizationParameters>
export type AuthorizationEndpointHandler = EndpointHandler<UrlParams>
export type TokenEndpointHandler = EndpointHandler<
UrlParams,
@@ -71,12 +57,12 @@ export type TokenEndpointHandler = EndpointHandler<
* Parameters extracted from the request to the `/api/auth/callback/:providerId` endpoint.
* Contains params like `state`.
*/
params: CallbackParamsType
params: URLSearchParams
/**
* When using this custom flow, make sure to do all the necessary security checks.
* Thist object contains parameters you have to match against the request to make sure it is valid.
* This object contains parameters you have to match against the request to make sure it is valid.
*/
checks: OAuthChecks
checks: URLSearchParams
},
{
tokens: TokenSet
@@ -86,7 +72,7 @@ export type TokenEndpointHandler = EndpointHandler<
export type UserinfoEndpointHandler = EndpointHandler<
UrlParams,
{ tokens: TokenSet },
Profile
Profile | Response
>
export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
@@ -112,7 +98,7 @@ export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
version?: string
profile?: (profile: P, tokens: TokenSet) => Awaitable<User & { id: string }>
checks?: ChecksType | ChecksType[]
client?: Partial<ClientMetadata>
client?: Partial<Client>
jwks?: { keys: JWK[] }
clientId?: string
clientSecret?: string
@@ -128,11 +114,6 @@ export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
idToken?: boolean
// TODO: only allow for BattleNet
region?: string
// TODO: only allow for some
issuer?: string
/** Read more at: https://github.com/panva/node-openid-client/tree/main/docs#customizing-http-requests */
httpOptions?: HttpOptions
/**
* The options provided by the user.
* We will perform a deep-merge of these values

View File

@@ -1,4 +1,10 @@
import type { OAuthConfig, OAuthUserConfig } from "."
import {
authorizationCodeGrantRequest,
AuthorizationServer,
isOAuth2Error,
processAuthorizationCodeOAuth2Response,
} from "@panva/oauth4webapi"
export interface TwitterLegacyProfile {
id: number
@@ -183,13 +189,33 @@ export default function Twitter<
url: "https://api.twitter.com/2/oauth2/token",
// TODO: Remove this
async request({ client, params, checks, provider }) {
const response = await client.oauthCallback(
provider.callbackUrl,
const as: AuthorizationServer = {
issuer: options.issuer as string,
// @ts-expect-error
token_endpoint: provider.token?.url,
}
const additionalParameters = new URLSearchParams()
additionalParameters.append("client_id", options.clientId)
const response = await authorizationCodeGrantRequest(
as,
client,
params,
checks,
{ exchangeBody: { client_id: options.clientId } }
provider.callbackUrl,
checks.get("code_verifier") as string,
{
additionalParameters,
}
)
return { tokens: response }
const tokens = await processAuthorizationCodeOAuth2Response(
as,
client,
response
)
if (isOAuth2Error(tokens)) {
throw new Error()
}
return { tokens }
},
},
userinfo: {

12276
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff