@eventuras/fides-auth 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oauth.js CHANGED
@@ -216,7 +216,7 @@ function buildSessionFromTokens(tokens, rolesClaim = "roles") {
216
216
  return {
217
217
  tokens: {
218
218
  accessToken: tokens.access_token,
219
- accessTokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3) : void 0,
219
+ accessTokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3).toISOString() : void 0,
220
220
  refreshToken: tokens.refresh_token
221
221
  },
222
222
  user: {
package/dist/oauth.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000)\n : undefined,\n refreshToken: tokens.refresh_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds a logout URL.\n * Returns null if the provider doesn't expose an end_session_endpoint.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param postLogoutRedirectUri - URL to redirect to after provider logout\n * @returns Logout URL or null if not supported\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n postLogoutRedirectUri: string,\n): Promise<URL | null> {\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set('client_id', oauthConfig.clientId);\n logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);\n\n logger.debug({ logoutUrl: logoutUrl.toString() }, 'Built OIDC logout URL');\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the token request fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig\n): Promise<openid.TokenEndpointResponse> {\n try {\n const params = new URLSearchParams({\n grant_type: 'client_credentials',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n });\n\n if (config.scope) {\n params.set('scope', config.scope);\n }\n\n const response = await fetch(config.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: params,\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n { status: response.status, error: errorText },\n 'Client credentials grant failed'\n );\n throw new Error(`Client credentials grant failed: ${response.status} - ${errorText}`);\n }\n\n return (await response.json()) as openid.TokenEndpointResponse;\n } catch (error) {\n logger.error({ error, tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant error');\n throw error;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: tokens.refresh_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds a logout URL.\n * Returns null if the provider doesn't expose an end_session_endpoint.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param postLogoutRedirectUri - URL to redirect to after provider logout\n * @returns Logout URL or null if not supported\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n postLogoutRedirectUri: string,\n): Promise<URL | null> {\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set('client_id', oauthConfig.clientId);\n logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);\n\n logger.debug({ logoutUrl: logoutUrl.toString() }, 'Built OIDC logout URL');\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the token request fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig\n): Promise<openid.TokenEndpointResponse> {\n try {\n const params = new URLSearchParams({\n grant_type: 'client_credentials',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n });\n\n if (config.scope) {\n params.set('scope', config.scope);\n }\n\n const response = await fetch(config.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: params,\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n { status: response.status, error: errorText },\n 'Client credentials grant failed'\n );\n throw new Error(`Client credentials grant failed: ${response.status} - ${errorText}`);\n }\n\n return (await response.json()) as openid.TokenEndpointResponse;\n } catch (error) {\n logger.error({ error, tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant error');\n throw error;\n }\n}\n"],"mappings":"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"}
@@ -25,7 +25,7 @@ var refreshSession = async (current_session, config, options = {}) => {
25
25
  tokens: {
26
26
  ...current_session.tokens,
27
27
  accessToken: newtokens.access_token,
28
- accessTokenExpiresAt: newtokens.expires_in ? new Date(Date.now() + newtokens.expires_in * 1e3) : void 0,
28
+ accessTokenExpiresAt: newtokens.expires_in ? new Date(Date.now() + newtokens.expires_in * 1e3).toISOString() : void 0,
29
29
  refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken
30
30
  }
31
31
  };
@@ -1 +1 @@
1
- {"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { OAuthConfig, refreshAccessToken } from \"./oauth\";\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000)\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAKA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;;;AAWvE,IAAa,iBAAiB,OAC5B,iBACA,QACA,UAAgC,CAAC,MACL;CAC5B,OAAO,MAAM,0BAA0B;CAEvC,IAAI,CAAC,gBAAgB,QAAQ,cAAc;EACzC,OAAO,MAAM,uCAAuC;EACpD,MAAM,IAAI,MAAM,4BAA4B;CAC9C;CAEA,IAAI;EACF,MAAM,YAAY,MAAM,mBAAmB,QAAQ,gBAAgB,OAAO,YAAY;EAEtF,MAAM,iBAAiB;GACrB,GAAG;GACH,QAAQ;IACN,GAAG,gBAAgB;IACnB,aAAa,UAAU;IACvB,sBAAsB,UAAU,aAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,UAAU,aAAa,GAAI,IACjD,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;GACnE;EACF;EAEA,OAAO,KAAK;GACV,mBAAmB,CAAC,CAAC,eAAe,OAAO;GAC3C,oBAAoB,CAAC,CAAC,UAAU;GAChC,WAAW,UAAU;EACvB,GAAG,gCAAgC;EAEnC,OAAO;CACT,SAAS,OAAO;EAEd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KAAK,2DAA2D;OAGvE,OAAO,MAAM,EAAE,OAAO,aAAa,GAAG,wBAAwB;EAGhE,MAAM;CACR;AACF"}
1
+ {"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { OAuthConfig, refreshAccessToken } from \"./oauth\";\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAKA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;;;AAWvE,IAAa,iBAAiB,OAC5B,iBACA,QACA,UAAgC,CAAC,MACL;CAC5B,OAAO,MAAM,0BAA0B;CAEvC,IAAI,CAAC,gBAAgB,QAAQ,cAAc;EACzC,OAAO,MAAM,uCAAuC;EACpD,MAAM,IAAI,MAAM,4BAA4B;CAC9C;CAEA,IAAI;EACF,MAAM,YAAY,MAAM,mBAAmB,QAAQ,gBAAgB,OAAO,YAAY;EAEtF,MAAM,iBAAiB;GACrB,GAAG;GACH,QAAQ;IACN,GAAG,gBAAgB;IACnB,aAAa,UAAU;IACvB,sBAAsB,UAAU,aAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,UAAU,aAAa,GAAI,EAAE,YAAY,IAC/D,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;GACnE;EACF;EAEA,OAAO,KAAK;GACV,mBAAmB,CAAC,CAAC,eAAe,OAAO;GAC3C,oBAAoB,CAAC,CAAC,UAAU;GAChC,WAAW,UAAU;EACvB,GAAG,gCAAgC;EAEnC,OAAO;CACT,SAAS,OAAO;EAEd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KAAK,2DAA2D;OAGvE,OAAO,MAAM,EAAE,OAAO,aAAa,GAAG,wBAAwB;EAGhE,MAAM;CACR;AACF"}
package/dist/types.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /** OAuth/OIDC token set stored in a session. */
2
2
  export interface Tokens {
3
3
  accessToken?: string;
4
- accessTokenExpiresAt?: Date;
4
+ /** ISO 8601 string — the session is a JSON/JWT envelope, so dates live as strings on the wire. */
5
+ accessTokenExpiresAt?: string;
5
6
  refreshToken?: string;
6
- refreshTokenExpiresAt?: Date;
7
+ /** ISO 8601 string — see {@link accessTokenExpiresAt}. */
8
+ refreshTokenExpiresAt?: string;
7
9
  }
8
10
  /** Authenticated user session with tokens, user info, and optional custom data. */
9
11
  export interface Session<TData = Record<string, unknown>> {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oBAAoB,CAAC,EAAE,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qBAAqB,CAAC,EAAE,IAAI,CAAC;CAC9B;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,KAAK,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,KAAK,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventuras/fides-auth",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Framework-agnostic OAuth/OIDC authentication library with PKCE, session management, and pluggable logging",
5
5
  "keywords": [
6
6
  "oauth",