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: option to use OAuth discovery endpoint for config #207

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
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
}
)
Loading