Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions packages/oauth/__tests__/codeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("buildRequestUrl", () => {
buildRequestUrl({
issuerURL: "http://issuer.com",
clientID: "12345",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})
).toBeInstanceOf(Promise)
})
Expand Down Expand Up @@ -136,7 +136,7 @@ describe("handleResponse", () => {
handleResponse({
issuerURL: "http://issuer.com",
clientID: "12345",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})
).rejects.toThrow("bad response, missing code param")
})
Expand All @@ -148,7 +148,7 @@ describe("handleResponse", () => {
handleResponse({
issuerURL: "http://issuer.com",
clientID: "12345",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})
).rejects.toThrow("unsupported_response_type")
})
Expand All @@ -157,9 +157,10 @@ describe("handleResponse", () => {
oidcState.setSearchParams(new URLSearchParams("code=test"))

await expect(
// @ts-expect-error - Testing missing required parameter
handleResponse({
issuerURL: "http://issuer.com",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})
).rejects.toThrow("clientID is required")
})
Expand All @@ -171,7 +172,7 @@ describe("handleResponse", () => {
await handleResponse({
issuerURL: "http://issuer.com",
clientID: "test",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})

expect(getOidcConfig).toHaveBeenCalledWith("http://issuer.com")
Expand All @@ -184,6 +185,8 @@ describe("handleResponse", () => {
issuerURL: "http://issuer.com",
clientID: "test",
oidcState: {
key: "test-key",
nonce: "test-nonce",
verifier: "12345",
},
})
Expand All @@ -198,16 +201,40 @@ describe("handleResponse", () => {
)
})

test("uses custom callbackURL in token exchange", async () => {
oidcState.setSearchParams(new URLSearchParams("code=12345678"))

await handleResponse({
issuerURL: "http://issuer.com",
clientID: "test",
oidcState: {
key: "test-key",
nonce: "test-nonce",
verifier: "12345",
callbackURL: "https://custom-callback.com",
},
})

expect(globalThis.fetch).toHaveBeenCalledWith(
"https://issuer.com/token",
expect.objectContaining({
headers: { "Content-Type": "application/x-www-form-urlencoded" },
method: "POST",
body: "grant_type=authorization_code&code=12345678&redirect_uri=https%3A%2F%2Fcustom-callback.com&client_id=test&code_verifier=12345",
})
)
})

test("fetch returns token data", async () => {
oidcState.setSearchParams(new URLSearchParams("code=123456789"))

const data = await handleResponse({
issuerURL: "http://issuer.com",
clientID: "test",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})

expect(data!.tokenData).toEqual(expect.objectContaining(testTokenData))
expect(data.tokenData).toEqual(expect.objectContaining(testTokenData))
})
})
})
Expand Down
2 changes: 1 addition & 1 deletion packages/oauth/__tests__/implicitFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("buildRequestUrl", () => {
buildRequestUrl({
issuerURL: "http://issuer.com",
clientID: "12345",
oidcState: {},
oidcState: { key: "test-key", nonce: "test-nonce" },
})
).toBeInstanceOf(Promise)
})
Expand Down
2 changes: 2 additions & 0 deletions packages/oauth/__tests__/mockedSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ describe("mockedSession", () => {
// onUpdate,
test("throw error on missing onUpdate callback", () => {
expect(() => {
// @ts-expect-error - Testing missing required parameter
mockedSession()
}).toThrow()
})

test("warn if unknown options", () => {
// @ts-expect-error - Testing unknown options
mockedSession({ onUpdate: () => null, test: "test", test2: "test" })

expect(globalThis.console.warn).toHaveBeenLastCalledWith(
Expand Down
5 changes: 5 additions & 0 deletions packages/oauth/__tests__/oidcSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("oidcSession", () => {
issuerURL: "http://dummy.com",
refresh: true,
requestParams: { organization: "Test" },
// @ts-expect-error - Testing unknown options
unknown: true,
test: "test",
})
Expand All @@ -32,19 +33,22 @@ describe("oidcSession", () => {
oidcSession({
clientID: "test",
issuerURL: "http://dummy.com",
// @ts-expect-error - Testing invalid type
onUpdate: true,
})
}).toThrow("(OAUTH) onUpdate should be a function")
})

test("issuerURL is required", () => {
expect(() => {
// @ts-expect-error - Testing missing required parameter
oidcSession({ clientID: "test" })
}).toThrow()
})

test("clientID is required", () => {
expect(() => {
// @ts-expect-error - Testing missing required parameter
oidcSession({ issuerURL: "http://dummy.com" })
}).toThrow()
})
Expand All @@ -58,6 +62,7 @@ describe("oidcSession", () => {
oidcSession({
issuerURL: "http://dummy.com",
clientID: "test",
// @ts-expect-error - Testing invalid flowType
flowType: "something",
})
}).toThrow("(OAUTH) flowType something is not supported!")
Expand Down
63 changes: 43 additions & 20 deletions packages/oauth/src/codeFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,33 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { TokenResponse, OidcStateData, FlowResponse } from "./types"
import { getOidcConfig } from "./oidcConfig"
import { decodeIDToken } from "./tokenHelpers"
import { searchParams } from "./oidcState"
import { paramsToUrl } from "./utils"

interface TokenResponse {
access_token: string
token_type: string
expires_in?: number
refresh_token?: string
scope?: string
id_token?: string
[key: string]: unknown // Optional: allows unexpected fields
}

interface ExchangeCodeParams {
tokenEndpoint: string
code: string
verifier?: string
clientID: string
callbackURL?: string
}

export const exchangeCode = async ({
tokenEndpoint,
code,
verifier,
clientID,
callbackURL,
}: ExchangeCodeParams): Promise<TokenResponse> => {
if (!clientID) throw new Error("clientID is required")

const body: Record<string, string> = {
grant_type: "authorization_code",
code,
redirect_uri: window.location.origin,
redirect_uri: callbackURL || window.location.origin,
client_id: clientID,
}

Expand All @@ -62,7 +55,21 @@ export const exchangeCode = async ({
return data
}

const buildRequestUrl = async ({ issuerURL, clientID, oidcState, params, callbackURL }: Record<string, any>) => {
interface CodeFlowParams {
issuerURL: string
clientID: string
oidcState: OidcStateData
params?: Record<string, string>
callbackURL?: string
}

const buildRequestUrl = async ({
issuerURL,
clientID,
oidcState,
params,
callbackURL,
}: CodeFlowParams): Promise<string> => {
const config = await getOidcConfig(issuerURL)

let scope = "openid email profile offline_access"
Expand All @@ -77,16 +84,22 @@ const buildRequestUrl = async ({ issuerURL, clientID, oidcState, params, callbac
scope,
state: oidcState.key,
nonce: oidcState.nonce,
code_challenge: oidcState.challenge,
code_challenge: oidcState.challenge || "",
code_challenge_method: "S256",
...params,
})

return config.authorization_endpoint + "?" + urlParams
}

const handleResponse = async ({ issuerURL, clientID, oidcState }: Record<string, any>) => {
if (!searchParams) return null
interface HandleResponseParams {
issuerURL: string
clientID: string
oidcState: OidcStateData
}

const handleResponse = async ({ issuerURL, clientID, oidcState }: HandleResponseParams): Promise<FlowResponse> => {
if (!searchParams) throw new Error("no search params available")

const code = searchParams.get("code")
const error = searchParams.get("error")
Expand All @@ -98,17 +111,20 @@ const handleResponse = async ({ issuerURL, clientID, oidcState }: Record<string,
const config = await getOidcConfig(issuerURL)
if (!config) throw new Error("could not load oidc config, issuerURL: " + issuerURL)

// Retrieve callbackURL from the persisted OIDC state
const callbackURL = typeof oidcState.callbackURL === "string" ? oidcState.callbackURL : undefined
Comment thread
ArtieReus marked this conversation as resolved.

const data = await exchangeCode({
tokenEndpoint: config.token_endpoint,
code,
verifier: oidcState.verifier,
clientID,
callbackURL,
})
if (!data?.id_token || typeof data.id_token !== "string") throw new Error("bad response, missing id_token")

const tokenData = decodeIDToken(data.id_token)
if (!tokenData) throw new Error("bad format of id_token")
if (!tokenData) throw new Error("bad format of id_token")

return {
tokenData,
Expand All @@ -117,13 +133,19 @@ const handleResponse = async ({ issuerURL, clientID, oidcState }: Record<string,
}
}

const refreshToken = async ({ issuerURL, clientID, refreshToken }: Record<string, any>) => {
interface RefreshTokenParams {
issuerURL: string
clientID: string
refreshToken: string
}

const refreshToken = async ({ issuerURL, clientID, refreshToken }: RefreshTokenParams): Promise<FlowResponse> => {
if (!issuerURL) throw new Error("issuerURL is required")
if (!clientID) throw new Error("clientID is required")

const config = await getOidcConfig(issuerURL)

const body: any = {
const body: Record<string, string> = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientID,
Expand All @@ -133,15 +155,16 @@ const refreshToken = async ({ issuerURL, clientID, refreshToken }: Record<string
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(body[k])}`)
.join("&")

const data = await fetch(config.token_endpoint, {
const data: TokenResponse = await fetch(config.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formBody,
}).then((r) => r.json())

if (data?.error) throw new Error(data.error)
if ("error" in data && data.error)
throw new Error(typeof data.error === "string" ? data.error : "Token refresh failed")
if (!data?.id_token) throw new Error("bad response, missing id_token")

const tokenData = decodeIDToken(data.id_token)
Expand Down
22 changes: 18 additions & 4 deletions packages/oauth/src/implicitFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { OidcStateData, FlowResponse } from "./types"
import { decodeIDToken } from "./tokenHelpers"
import { getOidcConfig } from "./oidcConfig"
import { searchParams } from "./oidcState"
import { paramsToUrl } from "./utils"

const buildRequestUrl = async ({ issuerURL, clientID, oidcState, callbackURL, params }: Record<string, any>) => {
interface ImplicitFlowParams {
issuerURL: string
clientID: string
oidcState: OidcStateData
callbackURL?: string
params?: Record<string, string>
}

const buildRequestUrl = async ({
issuerURL,
clientID,
oidcState,
callbackURL,
params,
}: ImplicitFlowParams): Promise<string> => {
const config = await getOidcConfig(issuerURL)

const urlParams = paramsToUrl({
Expand All @@ -26,11 +41,10 @@ const buildRequestUrl = async ({ issuerURL, clientID, oidcState, callbackURL, pa

/**
* Handle the implicit flow response (id token flow)
* @param {object} params
* @returns {Promise} resolves to token data
* @returns Promise resolves to token data or null
*/
// eslint-disable-next-line @typescript-eslint/require-await
const handleResponse = async () => {
const handleResponse = async (): Promise<FlowResponse | null> => {
if (!searchParams) return null

const idToken = searchParams.get("id_token")
Expand Down
20 changes: 20 additions & 0 deletions packages/oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,24 @@
import oidcSession from "./oidcSession"
import mockedSession from "./mockedSession"
import tokenSession from "./tokenSession"

export { oidcSession, mockedSession, tokenSession }

// Export types for consumers
export type {
AuthData,
ParsedTokenData,
IdTokenData,
SessionState,
FlowType,
OidcConfig,
OidcSessionParams,
OidcSessionInstance,
TokenSessionState,
TokenSessionParams,
TokenSessionInstance,
MockedSessionParams,
MockedSessionInstance,
Comment thread
ArtieReus marked this conversation as resolved.
TokenResponse,
OidcStateData,
} from "./types"
Loading
Loading