Skip to content

Commit

Permalink
feat: option to use OAuth discovery endpoint for config
Browse files Browse the repository at this point in the history
  • Loading branch information
soofstad committed Nov 11, 2024
1 parent 1a5b45e commit 1acec04
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 20 deletions.
1 change: 1 addition & 0 deletions src/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const AuthProvider = ({ authConfig, children }: IAuthProvider) => {
setRefreshTokenExpire(undefined)
setIdToken(undefined)
setLoginInProgress(false)
localStorage.removeItem(`${config.storageKeyPrefix}well_known`)
}

function logOut(state?: string, logoutHint?: string, additionalParameters?: TPrimitiveRecord) {
Expand Down
4 changes: 2 additions & 2 deletions src/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export function createInternalConfig(passedConfig: TAuthConfig): TInternalConfig
export function validateConfig(config: TInternalConfig) {
if (stringIsUnset(config?.clientId))
throw Error("'clientId' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider")
if (stringIsUnset(config?.authorizationEndpoint))
if (stringIsUnset(config?.authorizationEndpoint) && stringIsUnset(config?.discoveryEndpoint))
throw Error(
"'authorizationEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider"
)
if (stringIsUnset(config?.tokenEndpoint))
if (stringIsUnset(config?.tokenEndpoint) && stringIsUnset(config?.discoveryEndpoint))
throw Error(
"'tokenEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider"
)
Expand Down
46 changes: 39 additions & 7 deletions src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@ import type {
TTokenRequestForRefresh,
TTokenRequestWithCodeAndVerifier,
TTokenResponse,
TWellKnown,
} from './types'

const codeVerifierStorageKey = 'PKCE_code_verifier'
const stateStorageKey = 'ROCP_auth_state'

async function getPublicWellKnownConfig(config: TInternalConfig): Promise<TWellKnown> {
if (!config.discoveryEndpoint) throw Error('No "discoveryEndpoint" config parameter provided')
const storedConfig = localStorage.getItem(`${config.storageKeyPrefix}well_known`)
if (storedConfig) {
return new Promise((resolve) => resolve(JSON.parse(storedConfig)))
}
return fetch(config.discoveryEndpoint).then(async (response) => {
if (!response.ok) {
throw Error('Failed to fetch public well-known config')
}
const fetchedConfig = (await response.json()) as TWellKnown
localStorage.setItem(`${config.storageKeyPrefix}well_known`, JSON.stringify(fetchedConfig))
return fetchedConfig
})
}

export async function redirectToLogin(
config: TInternalConfig,
customState?: string,
Expand All @@ -26,7 +43,7 @@ export async function redirectToLogin(
storage.setItem(codeVerifierStorageKey, codeVerifier)

// Hash and Base64URL encode the code_verifier, used as the 'code_challenge'
return generateCodeChallenge(codeVerifier).then((codeChallenge) => {
return generateCodeChallenge(codeVerifier).then(async (codeChallenge) => {
// Set query parameters and redirect user to OAuth2 authentication endpoint
const params = new URLSearchParams({
response_type: 'code',
Expand All @@ -49,22 +66,22 @@ export async function redirectToLogin(
params.append('state', state)
}

const loginUrl = `${config.authorizationEndpoint}?${params.toString()}`
const loginUrl = config.authorizationEndpoint ?? (await getPublicWellKnownConfig(config)).authorization_endpoint

// Call any preLogin function in authConfig
if (config?.preLogin) config.preLogin()

if (method === 'popup') {
const { width, height, left, top } = calculatePopupPosition(600, 600)
const handle: null | WindowProxy = window.open(
loginUrl,
`${loginUrl}?${params.toString()}`,
'loginPopup',
`width=${width},height=${height},top=${top},left=${left}`
)
if (handle) return
console.warn('Popup blocked. Redirecting to login page. Disable popup blocker to use popup login.')
}
window.location.assign(loginUrl)
window.location.assign(`${loginUrl}?${params.toString()}`)
})
}

Expand Down Expand Up @@ -116,7 +133,11 @@ export const fetchTokens = (config: TInternalConfig): Promise<TTokenResponse> =>
// TODO: Remove in 2.0
...config.extraAuthParams,
}
return postTokenRequest(config.tokenEndpoint, tokenRequest, config.tokenRequestCredentials)
if (config.tokenEndpoint) return postTokenRequest(config.tokenEndpoint, tokenRequest, config.tokenRequestCredentials)

return getPublicWellKnownConfig(config).then((wellKnownConfig) =>
postTokenRequest(wellKnownConfig.token_endpoint, tokenRequest, config.tokenRequestCredentials)
)
}

export const fetchWithRefreshToken = (props: {
Expand All @@ -132,7 +153,12 @@ export const fetchWithRefreshToken = (props: {
...config.extraTokenParameters,
}
if (config.refreshWithScope) refreshRequest.scope = config.scope
return postTokenRequest(config.tokenEndpoint, refreshRequest, config.tokenRequestCredentials)
if (config.tokenEndpoint)
return postTokenRequest(config.tokenEndpoint, refreshRequest, config.tokenRequestCredentials)

return getPublicWellKnownConfig(config).then((wellKnownConfig) =>
postTokenRequest(wellKnownConfig.token_endpoint, refreshRequest, config.tokenRequestCredentials)
)
}

export function redirectToLogout(
Expand All @@ -156,7 +182,13 @@ export function redirectToLogout(
if (idToken) params.append('id_token_hint', idToken)
if (state) params.append('state', state)
if (logoutHint) params.append('logout_hint', logoutHint)
window.location.assign(`${config.logoutEndpoint}?${params.toString()}`)

if (config.logoutEndpoint) return window.location.assign(`${config.logoutEndpoint}?${params.toString()}`)

// TODO: This now removes the option to disable "true" logout. Make it configurable?
getPublicWellKnownConfig(config).then((wellKnownConfig) => {
window.location.assign(`${wellKnownConfig.revocation_endpoint}?${params.toString()}`)
})
}

export function validateState(urlParams: URLSearchParams, storageType: TInternalConfig['storage']) {
Expand Down
7 changes: 4 additions & 3 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { AuthContext, AuthProvider } from './AuthContext'
/** @type {import('./types').TAuthConfig} */
const authConfig = {
clientId: 'account',
authorizationEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/auth',
tokenEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/token',
logoutEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/logout',
discoveryEndpoint: 'https://keycloak.ofstad.xyz/realms/master/.well-known/openid-configuration',
// authorizationEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/auth',
// tokenEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/token',
// logoutEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/logout',
redirectUri: 'http://localhost:5173/',
onRefreshTokenExpire: (event) => event.logIn('', {}, 'popup'),
preLogin: () => console.log('Logging in...'),
Expand Down
50 changes: 42 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export type TTokenData = {
[x: string]: any
}

export type TWellKnown = {
authorization_endpoint: string
token_endpoint: string
revocation_endpoint: string
// biome-ignore lint: It really can be `any` (almost)
[x: string]: any
}

export type TTokenResponse = {
access_token: string
scope: string
Expand Down Expand Up @@ -61,11 +69,11 @@ export interface IAuthContext {

export type TPrimitiveRecord = { [key: string]: string | boolean | number }

// Input from users of the package, some optional values
export type TAuthConfig = {
type TAuthConfigBase = {
clientId: string
authorizationEndpoint: string
tokenEndpoint: string
authorizationEndpoint?: string
tokenEndpoint?: string
discoveryEndpoint?: string
redirectUri: string
scope?: string
state?: string
Expand All @@ -92,17 +100,29 @@ export type TAuthConfig = {
tokenRequestCredentials?: RequestCredentials
}

// Input from users of the package, some optional values
export type TAuthConfig = TAuthConfigBase &
(
| {
authorizationEndpoint: string
tokenEndpoint: string
discoveryEndpoint?: string
}
| {
discoveryEndpoint: string
tokenEndpoint?: string
authorizationEndpoint?: string
}
)

export type TRefreshTokenExpiredEvent = {
logIn: (state?: string, additionalParameters?: TPrimitiveRecord, method?: 'redirect' | 'popup') => void
/** @deprecated Use `logIn` instead. Will be removed in a future version. */
login: (state?: string, additionalParameters?: TPrimitiveRecord, method?: 'redirect' | 'popup') => void
}

// The AuthProviders internal config type. All values will be set by user provided, or default values
export type TInternalConfig = {
type TInternalConfigBase = {
clientId: string
authorizationEndpoint: string
tokenEndpoint: string
redirectUri: string
scope?: string
state?: string
Expand All @@ -128,3 +148,17 @@ export type TInternalConfig = {
refreshWithScope: boolean
tokenRequestCredentials: RequestCredentials
}
// The AuthProviders internal config type. All values will be set by user provided, or default values
export type TInternalConfig = TInternalConfigBase &
(
| {
authorizationEndpoint: string
tokenEndpoint: string
discoveryEndpoint?: string
}
| {
discoveryEndpoint: string
tokenEndpoint?: string
authorizationEndpoint?: string
}
)

0 comments on commit 1acec04

Please sign in to comment.