"]
6 | license = "MIT"
7 |
8 | [tool.poetry.dependencies]
9 | python = "^3.10"
10 | fastapi = "^0.78.0"
11 | uvicorn = "^0.18.2"
12 | requests = "^2.28.1"
13 | python-multipart = "^0.0.5"
14 |
15 | [tool.poetry.dev-dependencies]
16 |
17 | [build-system]
18 | requires = ["poetry-core>=1.0.0"]
19 | build-backend = "poetry.core.masonry.api"
20 |
--------------------------------------------------------------------------------
/examples/github-auth-provider/web-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | yarn.lock
25 |
--------------------------------------------------------------------------------
/examples/github-auth-provider/web-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine as base
2 | WORKDIR /app
3 | ADD package.json ./
4 | RUN yarn install
5 | ADD ./public ./public
6 | ADD ./src ./src
7 | CMD ["yarn", "start"]
--------------------------------------------------------------------------------
/examples/github-auth-provider/web-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gh-example-app",
3 | "version": "0.1.0",
4 | "dependencies": {
5 | "react": "^18.1.0",
6 | "react-dom": "^18.1.0",
7 | "react-oauth2-code-pkce": ">=1.8.4",
8 | "react-scripts": "^5.0.1"
9 | },
10 | "scripts": {
11 | "start": "react-scripts start",
12 | "build": "react-scripts build"
13 | },
14 | "devDependencies": {
15 | "@types/react": "^18.0.9",
16 | "typescript": "^4.4.3"
17 | },
18 | "browserslist": {
19 | "production": [">0.2%", "not dead", "not op_mini all"],
20 | "development": [
21 | "last 1 chrome version",
22 | "last 1 firefox version",
23 | "last 1 safari version"
24 | ]
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/github-auth-provider/web-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/examples/github-auth-provider/web-app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | // @ts-ignore
3 | import ReactDOM from 'react-dom'
4 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce'
5 |
6 | const authConfig: TAuthConfig = {
7 | clientId: 'c43524cc7d3c82b05a47',
8 | authorizationEndpoint: 'https://github.com/login/oauth/authorize',
9 | logoutEndpoint: 'https://github.com/login/oauth/logout',
10 | tokenEndpoint: 'http://localhost:5000/api/token',
11 | redirectUri: 'http://localhost:3000/',
12 | // Example to redirect back to original path after login has completed
13 | preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname),
14 | postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''),
15 | decodeToken: false,
16 | autoLogin: false,
17 | }
18 |
19 | function LoginInfo(): JSX.Element {
20 | const { tokenData, token, logIn, logOut, idToken, error }: IAuthContext = useContext(AuthContext)
21 |
22 | if (error) {
23 | return (
24 | <>
25 | An error occurred during authentication: {error}
26 |
27 | >
28 | )
29 | }
30 |
31 | return (
32 | <>
33 | {token ? (
34 | <>
35 |
36 |
Access Token (JWT)
37 |
47 | {token}
48 |
49 |
50 |
51 |
Login Information from Access Token (Base64 decoded JWT)
52 |
62 | {JSON.stringify(tokenData, null, 2)}
63 |
64 |
65 |
66 | >
67 | ) : (
68 | <>
69 | You are not logged in.
70 |
71 | >
72 | )}
73 | >
74 | )
75 | }
76 |
77 | ReactDOM.render(
78 |
79 |
92 |
93 | {/* @ts-ignore*/}
94 |
95 |
96 |
,
97 | document.getElementById('root')
98 | )
99 |
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | keycloak:
5 | image: quay.io/keycloak/keycloak:latest
6 | restart: unless-stopped
7 | volumes:
8 | - ./data:/opt/keycloak/data
9 | environment:
10 | KEYCLOAK_ADMIN: admin
11 | KEYCLOAK_ADMIN_PASSWORD: password
12 | ports:
13 | - "8080:8080"
14 | command: ["start-dev"]
15 | web:
16 | build: ./web-app
17 | restart: unless-stopped
18 | volumes:
19 | - ./web-app/src:/app/src
20 | ports:
21 | - "3000:3000"
22 |
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/web-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | yarn.lock
25 |
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/web-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine as base
2 | WORKDIR /app
3 | COPY package.json ./
4 | RUN yarn install
5 | COPY ./public ./public
6 | COPY ./src ./src
7 | CMD ["yarn", "start"]
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/web-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "keycloak-example-app",
3 | "version": "0.1.0",
4 | "dependencies": {
5 | "react": "^18.1.0",
6 | "react-dom": "^18.1.0",
7 | "react-oauth2-code-pkce": ">=1.10.1",
8 | "react-scripts": "^5.0.1"
9 | },
10 | "scripts": {
11 | "start": "react-scripts start",
12 | "build": "react-scripts build"
13 | },
14 | "devDependencies": {
15 | "@types/react": "^18.0.9",
16 | "typescript": "^4.4.3"
17 | },
18 | "browserslist": {
19 | "production": [">0.2%", "not dead", "not op_mini all"],
20 | "development": [
21 | "last 1 chrome version",
22 | "last 1 firefox version",
23 | "last 1 safari version"
24 | ]
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/web-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/examples/keycloak-auth-provider/web-app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | // @ts-ignore
3 | import ReactDOM from 'react-dom'
4 | import { createRoot } from 'react-dom/client'
5 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce'
6 |
7 | // Get info from http://localhost:8080/realms/test/.well-known/openid-configuration
8 |
9 | const authConfig: TAuthConfig = {
10 | clientId: 'account',
11 | authorizationEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/auth',
12 | logoutEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/logout',
13 | tokenEndpoint: 'http://localhost:8080/realms/test/protocol/openid-connect/token',
14 | redirectUri: 'http://localhost:3000/',
15 | scope: 'profile openid',
16 | // Example to redirect back to original path after login has completed
17 | // preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname),
18 | // postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''),
19 | decodeToken: true,
20 | autoLogin: false,
21 | }
22 |
23 | function LoginInfo(): JSX.Element {
24 | const { tokenData, token, logIn, logOut, idToken, error }: IAuthContext = useContext(AuthContext)
25 |
26 | if (error) {
27 | return (
28 | <>
29 | An error occurred during authentication: {error}
30 |
31 | >
32 | )
33 | }
34 |
35 | return (
36 | <>
37 | {token ? (
38 | <>
39 |
40 |
Access Token (JWT)
41 |
51 | {token}
52 |
53 |
54 |
55 |
Login Information from Access Token (Base64 decoded JWT)
56 |
66 | {JSON.stringify(tokenData, null, 2)}
67 |
68 |
69 |
70 | >
71 | ) : (
72 | <>
73 | You are not logged in.
74 |
75 | >
76 | )}
77 | >
78 | )
79 | }
80 |
81 | const container = document.getElementById('root')
82 | const root = createRoot(container)
83 |
84 | root.render(
85 |
86 |
99 |
100 | {/* @ts-ignore*/}
101 |
102 |
103 |
,
104 | document.getElementById('root')
105 | )
106 |
--------------------------------------------------------------------------------
/examples/microsoft-auth-provider/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | web:
5 | build: ./web-app
6 | restart: unless-stopped
7 | volumes:
8 | - ./web-app/src:/app/src
9 | ports:
10 | - "3000:3000"
11 |
--------------------------------------------------------------------------------
/examples/microsoft-auth-provider/web-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine as base
2 | WORKDIR /app
3 | ADD package.json ./
4 | RUN yarn install
5 | ADD ./public ./public
6 | ADD ./src ./src
7 | CMD ["yarn", "start"]
--------------------------------------------------------------------------------
/examples/microsoft-auth-provider/web-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ms-example-app",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "start": "ESLINT_NO_DEV_ERRORS='true' react-scripts start",
6 | "build": "react-scripts build"
7 | },
8 | "dependencies": {
9 | "react": "^18.2.0",
10 | "react-dom": "^18.2.0",
11 | "react-oauth2-code-pkce": "1.17.1",
12 | "react-scripts": "^5.0.1"
13 | },
14 | "devDependencies": {
15 | "@types/react": "^18.2.58",
16 | "typescript": "^5.3.3"
17 | },
18 | "browserslist": {
19 | "production": [">0.2%", "not dead", "not op_mini all"],
20 | "development": [
21 | "last 1 chrome version",
22 | "last 1 firefox version",
23 | "last 1 safari version"
24 | ]
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/microsoft-auth-provider/web-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/examples/microsoft-auth-provider/web-app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | // @ts-ignore
3 | import ReactDOM from 'react-dom'
4 | import { AuthContext, AuthProvider, type IAuthContext, type TAuthConfig } from 'react-oauth2-code-pkce'
5 |
6 | const authConfig: TAuthConfig = {
7 | clientId: '6559ce69-219d-4e82-b6ed-889a861c7c94',
8 | authorizationEndpoint:
9 | 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/authorize',
10 | tokenEndpoint: 'https://login.microsoftonline.com/d422398d-b6a5-454d-a202-7ed4c1bec457/oauth2/v2.0/token',
11 | redirectUri: 'http://localhost:3000/',
12 | onRefreshTokenExpire: (event) =>
13 | window.confirm('Tokens have expired. Refresh page to continue using the site?') && event.logIn(),
14 | // Example to redirect back to original path after login has completed
15 | preLogin: () => localStorage.setItem('preLoginPath', window.location.pathname),
16 | postLogin: () => window.location.replace(localStorage.getItem('preLoginPath') || ''),
17 | decodeToken: true,
18 | scope: 'User.read',
19 | autoLogin: false,
20 | }
21 |
22 | function LoginInfo(): JSX.Element {
23 | const { tokenData, token, logOut, idToken, error, logIn }: IAuthContext = useContext(AuthContext)
24 |
25 | if (error) {
26 | return (
27 | <>
28 | An error occurred during authentication: {error}
29 |
32 | >
33 | )
34 | }
35 |
36 | return (
37 | <>
38 | {token ? (
39 |
50 |
61 |
Welcome, John Doe!
62 |
63 |
66 |
67 |
Use this token to authenticate yourself
68 |
78 | {token}
79 |
80 |
81 |
82 | ) : (
83 |
94 |
105 |
Please login to continue
106 |
107 |
110 |
111 |
112 | )}
113 | >
114 | )
115 | }
116 |
117 | ReactDOM.render(
118 |
119 |
130 |
Demo using
131 | react-oauth2-code-pkce
132 |
133 |
134 | {/* @ts-ignore*/}
135 |
136 |
137 |
,
138 | document.getElementById('root')
139 | )
140 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | React App
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | transform: { '^.+\\.(ts|tsx)?$': 'ts-jest' },
3 | testEnvironment: 'jsdom',
4 | testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
6 | setupFilesAfterEnv: ['./tests/jestSetup.js'],
7 | }
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "package-development",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "start": "vite",
6 | "build": "vite build",
7 | "test": "jest --silent",
8 | "test:watch": "jest --silent --watch"
9 | },
10 | "dependencies": {
11 | "react": "18.3.1",
12 | "react-dom": "18.3.1"
13 | },
14 | "devDependencies": {
15 | "@testing-library/dom": "^10.4.0",
16 | "@testing-library/jest-dom": "^6.6.3",
17 | "@testing-library/react": "^16.0.1",
18 | "@testing-library/user-event": "^14.5.2",
19 | "@types/jest": "^29.5.14",
20 | "@types/react": "18.3.12",
21 | "@vitejs/plugin-react": "^4.3.3",
22 | "jest": "^29.7.0",
23 | "jest-environment-jsdom": "^29.7.0",
24 | "ts-jest": "^29.2.5",
25 | "typescript": "5.6.3",
26 | "vite": "^5.4.10"
27 | },
28 | "browserslist": {
29 | "production": [">0.2%", "not dead", "not op_mini all"],
30 | "development": [
31 | "last 1 chrome version",
32 | "last 1 firefox version",
33 | "last 1 safari version"
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/publish.md:
--------------------------------------------------------------------------------
1 | # How to create a new release
2 |
3 | ```bash
4 | # Bump version in './src/package.json'
5 | git commit -m "bump version"
6 | git tag v?.?.? -m "A Message"
7 | git push --tags
8 | ```
9 |
--------------------------------------------------------------------------------
/src/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useEffect, useMemo, useRef, useState } from 'react'
2 | import useBrowserStorage from './Hooks'
3 | import { createInternalConfig } from './authConfig'
4 | import { fetchTokens, fetchWithRefreshToken, redirectToLogin, redirectToLogout, validateState } from './authentication'
5 | import { decodeAccessToken, decodeIdToken, decodeJWT } from './decodeJWT'
6 | import { FetchError } from './errors'
7 | import { FALLBACK_EXPIRE_TIME, epochAtSecondsFromNow, epochTimeIsPast, getRefreshExpiresIn } from './timeUtils'
8 | import type {
9 | IAuthContext,
10 | IAuthProvider,
11 | TInternalConfig,
12 | TLoginMethod,
13 | TPrimitiveRecord,
14 | TRefreshTokenExpiredEvent,
15 | TTokenData,
16 | TTokenResponse,
17 | } from './types'
18 |
19 | export const AuthContext = createContext({
20 | token: '',
21 | login: () => null,
22 | logIn: () => null,
23 | logOut: () => null,
24 | error: null,
25 | loginInProgress: false,
26 | })
27 |
28 | export const AuthProvider = ({ authConfig, children }: IAuthProvider) => {
29 | const config: TInternalConfig = useMemo(() => createInternalConfig(authConfig), [authConfig])
30 |
31 | const [refreshToken, setRefreshToken] = useBrowserStorage(
32 | `${config.storageKeyPrefix}refreshToken`,
33 | undefined,
34 | config.storage
35 | )
36 | const [refreshTokenExpire, setRefreshTokenExpire] = useBrowserStorage(
37 | `${config.storageKeyPrefix}refreshTokenExpire`,
38 | undefined,
39 | config.storage
40 | )
41 | const [token, setToken] = useBrowserStorage(`${config.storageKeyPrefix}token`, '', config.storage)
42 | const [tokenExpire, setTokenExpire] = useBrowserStorage(
43 | `${config.storageKeyPrefix}tokenExpire`,
44 | epochAtSecondsFromNow(FALLBACK_EXPIRE_TIME),
45 | config.storage
46 | )
47 | const [idToken, setIdToken] = useBrowserStorage(
48 | `${config.storageKeyPrefix}idToken`,
49 | undefined,
50 | config.storage
51 | )
52 | const [loginInProgress, setLoginInProgress] = useBrowserStorage(
53 | `${config.storageKeyPrefix}loginInProgress`,
54 | false,
55 | config.storage
56 | )
57 | const [refreshInProgress, setRefreshInProgress] = useBrowserStorage(
58 | `${config.storageKeyPrefix}refreshInProgress`,
59 | false,
60 | config.storage
61 | )
62 | const [loginMethod, setLoginMethod] = useBrowserStorage(
63 | `${config.storageKeyPrefix}loginMethod`,
64 | 'redirect',
65 | config.storage
66 | )
67 | const tokenData = useMemo(() => {
68 | if (config.decodeToken) return decodeAccessToken(token)
69 | }, [token])
70 | const idTokenData = useMemo(() => decodeIdToken(idToken), [idToken])
71 | const [error, setError] = useState(null)
72 |
73 | function clearStorage() {
74 | setRefreshToken(undefined)
75 | setToken('')
76 | setTokenExpire(epochAtSecondsFromNow(FALLBACK_EXPIRE_TIME))
77 | setRefreshTokenExpire(undefined)
78 | setIdToken(undefined)
79 | setLoginInProgress(false)
80 | }
81 |
82 | function logOut(state?: string, logoutHint?: string, additionalParameters?: TPrimitiveRecord) {
83 | clearStorage()
84 | setError(null)
85 | if (config?.logoutEndpoint && token)
86 | redirectToLogout(config, token, refreshToken, idToken, state, logoutHint, additionalParameters)
87 | }
88 |
89 | function logIn(state?: string, additionalParameters?: TPrimitiveRecord, method: TLoginMethod = 'redirect') {
90 | clearStorage()
91 | setLoginInProgress(true)
92 | setLoginMethod(method)
93 | // TODO: Raise error on wrong state type in v2
94 | let typeSafePassedState = state
95 | if (state && typeof state !== 'string') {
96 | const jsonState = JSON.stringify(state)
97 | console.warn(
98 | `Passed login state must be of type 'string'. Received '${jsonState}'. Ignoring value. In a future version, an error will be thrown here.`
99 | )
100 | typeSafePassedState = undefined
101 | }
102 | redirectToLogin(config, typeSafePassedState, additionalParameters, method).catch((error) => {
103 | console.error(error)
104 | setError(error.message)
105 | setLoginInProgress(false)
106 | })
107 | }
108 |
109 | function handleTokenResponse(response: TTokenResponse) {
110 | setToken(response.access_token)
111 | if (response.id_token) {
112 | setIdToken(response.id_token)
113 | }
114 | let tokenExp = FALLBACK_EXPIRE_TIME
115 | // Decode IdToken, so we can use "exp" from that as fallback if expire not returned in the response
116 | try {
117 | if (response.id_token) {
118 | const decodedToken = decodeJWT(response.id_token)
119 | tokenExp = Math.round(Number(decodedToken.exp) - Date.now() / 1000) // number of seconds from now
120 | }
121 | } catch (e) {
122 | console.warn(`Failed to decode idToken: ${(e as Error).message}`)
123 | }
124 | const tokenExpiresIn = config.tokenExpiresIn ?? response.expires_in ?? tokenExp
125 | setTokenExpire(epochAtSecondsFromNow(tokenExpiresIn))
126 | const refreshTokenExpiresIn = config.refreshTokenExpiresIn ?? getRefreshExpiresIn(tokenExpiresIn, response)
127 | if (response.refresh_token) {
128 | setRefreshToken(response.refresh_token)
129 | if (!refreshTokenExpire || config.refreshTokenExpiryStrategy !== 'absolute') {
130 | setRefreshTokenExpire(epochAtSecondsFromNow(refreshTokenExpiresIn))
131 | }
132 | }
133 | }
134 |
135 | function handleExpiredRefreshToken(initial = false): void {
136 | if (config.autoLogin && initial) return logIn(undefined, undefined, config.loginMethod)
137 |
138 | // TODO: Breaking change - remove automatic login during ongoing session
139 | if (!config.onRefreshTokenExpire) return logIn(undefined, undefined, config.loginMethod)
140 |
141 | config.onRefreshTokenExpire({
142 | login: logIn,
143 | logIn,
144 | } as TRefreshTokenExpiredEvent)
145 | }
146 |
147 | function refreshAccessToken(initial = false): void {
148 | if (!token) return
149 | // The token has not expired. Do nothing
150 | if (!epochTimeIsPast(tokenExpire)) return
151 |
152 | // Other instance (tab) is currently refreshing. This instance skip the refresh if not initial
153 | if (refreshInProgress && !initial) return
154 |
155 | // If no refreshToken, act as if the refreshToken expired (session expired)
156 | if (!refreshToken) return handleExpiredRefreshToken(initial)
157 |
158 | // The refreshToken has expired
159 | if (refreshTokenExpire && epochTimeIsPast(refreshTokenExpire)) return handleExpiredRefreshToken(initial)
160 |
161 | // The access_token has expired, and we have a non-expired refresh_token. Use it to refresh access_token.
162 | if (refreshToken) {
163 | setRefreshInProgress(true)
164 | fetchWithRefreshToken({ config, refreshToken })
165 | .then((result: TTokenResponse) => handleTokenResponse(result))
166 | .catch((error: unknown) => {
167 | if (error instanceof FetchError) {
168 | // If the fetch failed with status 400, assume expired refresh token
169 | if (error.status === 400) {
170 | handleExpiredRefreshToken(initial)
171 | return
172 | }
173 | // Unknown error. Set error, and log in if first page load
174 | console.error(error)
175 | setError(error.message)
176 | if (initial) logIn(undefined, undefined, config.loginMethod)
177 | }
178 | // Unknown error. Set error, and log in if first page load
179 | else if (error instanceof Error) {
180 | console.error(error)
181 | setError(error.message)
182 | if (initial) logIn(undefined, undefined, config.loginMethod)
183 | }
184 | })
185 | .finally(() => {
186 | setRefreshInProgress(false)
187 | })
188 | return
189 | }
190 | console.warn(
191 | 'Failed to refresh access_token. Most likely there is no refresh_token, or the authentication server did not reply with an explicit expire time, and the default expire times are longer than the actual tokens expire time'
192 | )
193 | }
194 |
195 | // Register the 'check for soon expiring access token' interval (every ~10 seconds).
196 | useEffect(() => {
197 | // The randomStagger is used to avoid multiple tabs logging in at the exact same time.
198 | const randomStagger = 10000 * Math.random()
199 | const interval = setInterval(() => refreshAccessToken(), 5000 + randomStagger)
200 | return () => clearInterval(interval)
201 | }, [token, refreshToken, refreshTokenExpire, tokenExpire, refreshInProgress]) // Replace the interval with a new when values used inside refreshAccessToken changes
202 |
203 | // This ref is used to make sure the 'fetchTokens' call is only made once.
204 | // Multiple calls with the same code will, and should, return an error from the API
205 | // See: https://beta.reactjs.org/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
206 | const didFetchTokens = useRef(false)
207 |
208 | // Runs once on page load
209 | useEffect(() => {
210 | // The client has been redirected back from the auth endpoint with an auth code
211 | if (loginInProgress) {
212 | const urlParams = new URLSearchParams(window.location.search)
213 | if (!urlParams.get('code')) {
214 | // This should not happen. There should be a 'code' parameter in the url by now...
215 | const error_description =
216 | urlParams.get('error_description') ||
217 | 'Bad authorization state. Refreshing the page and log in again might solve the issue.'
218 | console.error(
219 | `${error_description}\nExpected to find a '?code=' parameter in the URL by now. Did the authentication get aborted or interrupted?`
220 | )
221 | setError(error_description)
222 | clearStorage()
223 | return
224 | }
225 | // Make sure we only try to use the auth code once
226 | if (!didFetchTokens.current) {
227 | didFetchTokens.current = true
228 | try {
229 | validateState(urlParams, config.storage)
230 | } catch (e: unknown) {
231 | console.error(e)
232 | setError((e as Error).message)
233 | }
234 | // Request tokens from auth server with the auth code
235 | fetchTokens(config)
236 | .then((tokens: TTokenResponse) => {
237 | handleTokenResponse(tokens)
238 | // Call any postLogin function in authConfig
239 | if (config?.postLogin) config.postLogin()
240 | if (loginMethod === 'popup') window.close()
241 | })
242 | .catch((error: Error) => {
243 | console.error(error)
244 | setError(error.message)
245 | })
246 | .finally(() => {
247 | if (config.clearURL) {
248 | // Clear ugly url params
249 | window.history.replaceState(null, '', `${window.location.pathname}${window.location.hash}`)
250 | }
251 | setLoginInProgress(false)
252 | })
253 | }
254 | return
255 | }
256 |
257 | // First page visit
258 | if (!token && config.autoLogin) return logIn(undefined, undefined, config.loginMethod)
259 | refreshAccessToken(true) // Check if token should be updated
260 | }, [])
261 |
262 | return (
263 |
276 | {children}
277 |
278 | )
279 | }
280 |
--------------------------------------------------------------------------------
/src/Hooks.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | function useBrowserStorage(key: string, initialValue: T, type: 'session' | 'local'): [T, (v: T) => void] {
4 | const storage = type === 'session' ? sessionStorage : localStorage
5 |
6 | const [storedValue, setStoredValue] = useState(() => {
7 | const item = storage.getItem(key)
8 | try {
9 | return item ? JSON.parse(item) : initialValue
10 | } catch (error: unknown) {
11 | console.warn(`Failed to parse stored value for '${key}'.\nContinuing with default value.`)
12 | return initialValue
13 | }
14 | })
15 |
16 | const setValue = (value: T | ((val: T) => T)): void => {
17 | if (value === undefined) {
18 | // Delete item if set to undefined. This avoids warning on loading invalid json
19 | setStoredValue(value)
20 | storage.removeItem(key)
21 | return
22 | }
23 | try {
24 | const valueToStore = value instanceof Function ? value(storedValue) : value
25 | setStoredValue(valueToStore)
26 | storage.setItem(key, JSON.stringify(valueToStore))
27 | } catch (error) {
28 | console.error(`Failed to store value '${value}' for key '${key}'`)
29 | }
30 | }
31 |
32 | useEffect(() => {
33 | const storageEventHandler = (event: StorageEvent) => {
34 | if (event.storageArea === storage && event.key === key) {
35 | if (event.newValue === null) {
36 | setStoredValue(undefined as T)
37 | } else {
38 | try {
39 | setStoredValue(JSON.parse(event.newValue ?? '') as T)
40 | } catch (error: unknown) {
41 | console.warn(`Failed to handle storageEvent's newValue='${event.newValue}' for key '${key}'`)
42 | }
43 | }
44 | }
45 | }
46 | window.addEventListener('storage', storageEventHandler, false)
47 | return () => window.removeEventListener('storage', storageEventHandler, false)
48 | })
49 |
50 | return [storedValue, setValue]
51 | }
52 |
53 | export default useBrowserStorage
54 |
--------------------------------------------------------------------------------
/src/authConfig.ts:
--------------------------------------------------------------------------------
1 | import type { TAuthConfig, TInternalConfig } from './types'
2 |
3 | function stringIsUnset(value: string | null | undefined) {
4 | const unset = ['', undefined, null]
5 | return unset.includes(value)
6 | }
7 |
8 | export function createInternalConfig(passedConfig: TAuthConfig): TInternalConfig {
9 | // Set default values for internal config object
10 | const {
11 | autoLogin = true,
12 | clearURL = true,
13 | decodeToken = true,
14 | scope = undefined,
15 | preLogin = () => null,
16 | postLogin = () => null,
17 | loginMethod = 'redirect',
18 | onRefreshTokenExpire = undefined,
19 | storage = 'local',
20 | storageKeyPrefix = 'ROCP_',
21 | refreshWithScope = true,
22 | refreshTokenExpiryStrategy = 'renewable',
23 | tokenRequestCredentials = 'same-origin',
24 | }: TAuthConfig = passedConfig
25 |
26 | const config: TInternalConfig = {
27 | ...passedConfig,
28 | autoLogin: autoLogin,
29 | clearURL: clearURL,
30 | decodeToken: decodeToken,
31 | scope: scope,
32 | preLogin: preLogin,
33 | postLogin: postLogin,
34 | loginMethod: loginMethod,
35 | onRefreshTokenExpire: onRefreshTokenExpire,
36 | storage: storage,
37 | storageKeyPrefix: storageKeyPrefix,
38 | refreshWithScope: refreshWithScope,
39 | refreshTokenExpiryStrategy: refreshTokenExpiryStrategy,
40 | tokenRequestCredentials: tokenRequestCredentials,
41 | }
42 | validateConfig(config)
43 | return config
44 | }
45 |
46 | export function validateConfig(config: TInternalConfig) {
47 | if (stringIsUnset(config?.clientId))
48 | throw Error("'clientId' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider")
49 | if (stringIsUnset(config?.authorizationEndpoint))
50 | throw Error(
51 | "'authorizationEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider"
52 | )
53 | if (stringIsUnset(config?.tokenEndpoint))
54 | throw Error(
55 | "'tokenEndpoint' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider"
56 | )
57 | if (stringIsUnset(config?.redirectUri))
58 | throw Error("'redirectUri' must be set in the 'AuthConfig' object passed to 'react-oauth2-code-pkce' AuthProvider")
59 | if (!['session', 'local'].includes(config.storage)) throw Error("'storage' must be one of ('session', 'local')")
60 | if (config?.extraAuthParams)
61 | console.warn(
62 | "The 'extraAuthParams' configuration parameter will be deprecated. You should use " +
63 | "'extraTokenParameters' instead."
64 | )
65 | if (config?.extraAuthParams && config?.extraTokenParameters)
66 | console.warn(
67 | "Using both 'extraAuthParams' and 'extraTokenParameters' is not recommended. " +
68 | "They do the same thing, and you should only use 'extraTokenParameters'"
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/src/authentication.ts:
--------------------------------------------------------------------------------
1 | import { postWithXForm } from './httpUtils'
2 | import { generateCodeChallenge, generateRandomString } from './pkceUtils'
3 | import { calculatePopupPosition } from './popupUtils'
4 | import type {
5 | TInternalConfig,
6 | TLoginMethod,
7 | TPrimitiveRecord,
8 | TTokenRequest,
9 | TTokenRequestForRefresh,
10 | TTokenRequestWithCodeAndVerifier,
11 | TTokenResponse,
12 | } from './types'
13 |
14 | const codeVerifierStorageKey = 'PKCE_code_verifier'
15 | const stateStorageKey = 'ROCP_auth_state'
16 |
17 | export async function redirectToLogin(
18 | config: TInternalConfig,
19 | customState?: string,
20 | additionalParameters?: TPrimitiveRecord,
21 | method: TLoginMethod = 'redirect'
22 | ): Promise {
23 | const storage = config.storage === 'session' ? sessionStorage : localStorage
24 | const navigationMethod = method === 'replace' ? 'replace' : 'assign'
25 |
26 | // Create and store a random string in storage, used as the 'code_verifier'
27 | const codeVerifier = generateRandomString(96)
28 | storage.setItem(codeVerifierStorageKey, codeVerifier)
29 |
30 | // Hash and Base64URL encode the code_verifier, used as the 'code_challenge'
31 | return generateCodeChallenge(codeVerifier).then((codeChallenge) => {
32 | // Set query parameters and redirect user to OAuth2 authentication endpoint
33 | const params = new URLSearchParams({
34 | response_type: 'code',
35 | client_id: config.clientId,
36 | redirect_uri: config.redirectUri,
37 | code_challenge: codeChallenge,
38 | code_challenge_method: 'S256',
39 | ...config.extraAuthParameters,
40 | ...additionalParameters,
41 | })
42 |
43 | if (config.scope !== undefined && !params.has('scope')) {
44 | params.append('scope', config.scope)
45 | }
46 |
47 | storage.removeItem(stateStorageKey)
48 | const state = customState ?? config.state
49 | if (state) {
50 | storage.setItem(stateStorageKey, state)
51 | params.append('state', state)
52 | }
53 |
54 | const loginUrl = `${config.authorizationEndpoint}?${params.toString()}`
55 |
56 | // Call any preLogin function in authConfig
57 | if (config?.preLogin) config.preLogin()
58 |
59 | if (method === 'popup') {
60 | const { width, height, left, top } = calculatePopupPosition(600, 600)
61 | const handle: null | WindowProxy = window.open(
62 | loginUrl,
63 | 'loginPopup',
64 | `width=${width},height=${height},top=${top},left=${left}`
65 | )
66 | if (handle) return
67 | console.warn('Popup blocked. Redirecting to login page. Disable popup blocker to use popup login.')
68 | }
69 | window.location[navigationMethod](loginUrl)
70 | })
71 | }
72 |
73 | // This is called a "type predicate". Which allow us to know which kind of response we got, in a type safe way.
74 | function isTokenResponse(body: unknown | TTokenResponse): body is TTokenResponse {
75 | return (body as TTokenResponse).access_token !== undefined
76 | }
77 |
78 | function postTokenRequest(
79 | tokenEndpoint: string,
80 | tokenRequest: TTokenRequest,
81 | credentials: RequestCredentials
82 | ): Promise {
83 | return postWithXForm({ url: tokenEndpoint, request: tokenRequest, credentials: credentials }).then((response) => {
84 | return response.json().then((body: TTokenResponse | unknown): TTokenResponse => {
85 | if (isTokenResponse(body)) {
86 | return body
87 | }
88 | throw Error(JSON.stringify(body))
89 | })
90 | })
91 | }
92 |
93 | export const fetchTokens = (config: TInternalConfig): Promise => {
94 | const storage = config.storage === 'session' ? sessionStorage : localStorage
95 | /*
96 | The browser has been redirected from the authentication endpoint with
97 | a 'code' url parameter.
98 | This code will now be exchanged for Access- and Refresh Tokens.
99 | */
100 | const urlParams = new URLSearchParams(window.location.search)
101 | const authCode = urlParams.get('code')
102 | const codeVerifier = storage.getItem(codeVerifierStorageKey)
103 |
104 | if (!authCode) {
105 | throw Error("Parameter 'code' not found in URL. \nHas authentication taken place?")
106 | }
107 | if (!codeVerifier) {
108 | throw Error("Can't get tokens without the CodeVerifier. \nHas authentication taken place?")
109 | }
110 |
111 | const tokenRequest: TTokenRequestWithCodeAndVerifier = {
112 | grant_type: 'authorization_code',
113 | code: authCode,
114 | client_id: config.clientId,
115 | redirect_uri: config.redirectUri,
116 | code_verifier: codeVerifier,
117 | ...config.extraTokenParameters,
118 | // TODO: Remove in 2.0
119 | ...config.extraAuthParams,
120 | }
121 | return postTokenRequest(config.tokenEndpoint, tokenRequest, config.tokenRequestCredentials)
122 | }
123 |
124 | export const fetchWithRefreshToken = (props: {
125 | config: TInternalConfig
126 | refreshToken: string
127 | }): Promise => {
128 | const { config, refreshToken } = props
129 | const refreshRequest: TTokenRequestForRefresh = {
130 | grant_type: 'refresh_token',
131 | refresh_token: refreshToken,
132 | client_id: config.clientId,
133 | redirect_uri: config.redirectUri,
134 | ...config.extraTokenParameters,
135 | }
136 | if (config.refreshWithScope) refreshRequest.scope = config.scope
137 | return postTokenRequest(config.tokenEndpoint, refreshRequest, config.tokenRequestCredentials)
138 | }
139 |
140 | export function redirectToLogout(
141 | config: TInternalConfig,
142 | token: string,
143 | refresh_token?: string,
144 | idToken?: string,
145 | state?: string,
146 | logoutHint?: string,
147 | additionalParameters?: TPrimitiveRecord
148 | ) {
149 | const params = new URLSearchParams({
150 | token: refresh_token || token,
151 | token_type_hint: refresh_token ? 'refresh_token' : 'access_token',
152 | client_id: config.clientId,
153 | post_logout_redirect_uri: config.logoutRedirect ?? config.redirectUri,
154 | ui_locales: window.navigator.languages.join(' '),
155 | ...config.extraLogoutParameters,
156 | ...additionalParameters,
157 | })
158 | if (idToken) params.append('id_token_hint', idToken)
159 | if (state) params.append('state', state)
160 | if (logoutHint) params.append('logout_hint', logoutHint)
161 | window.location.assign(`${config.logoutEndpoint}?${params.toString()}`)
162 | }
163 |
164 | export function validateState(urlParams: URLSearchParams, storageType: TInternalConfig['storage']) {
165 | const storage = storageType === 'session' ? sessionStorage : localStorage
166 | const receivedState = urlParams.get('state')
167 | const loadedState = storage.getItem(stateStorageKey)
168 | if (receivedState !== loadedState) {
169 | throw new Error(
170 | '"state" value received from authentication server does no match client request. Possible cross-site request forgery'
171 | )
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/decodeJWT.ts:
--------------------------------------------------------------------------------
1 | import type { TTokenData } from './types'
2 |
3 | /**
4 | * Decodes the base64 encoded JWT. Returns a TToken.
5 | */
6 | export const decodeJWT = (token: string): TTokenData => {
7 | try {
8 | const base64Url = token.split('.')[1]
9 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
10 | const jsonPayload = decodeURIComponent(
11 | atob(base64)
12 | .split('')
13 | .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
14 | .join('')
15 | )
16 | return JSON.parse(jsonPayload)
17 | } catch (e) {
18 | console.error(e)
19 | throw Error(
20 | 'Failed to decode the access token.\n\tIs it a proper JSON Web Token?\n\t' +
21 | "You can disable JWT decoding by setting the 'decodeToken' value to 'false' the configuration."
22 | )
23 | }
24 | }
25 |
26 | export const decodeAccessToken = (token: string | null | undefined): TTokenData | undefined => {
27 | if (!token || !token.length) return undefined
28 | try {
29 | return decodeJWT(token)
30 | } catch (e) {
31 | console.warn(`Failed to decode access token: ${(e as Error).message}`)
32 | }
33 | }
34 |
35 | export const decodeIdToken = (idToken: string | null | undefined): TTokenData | undefined => {
36 | if (!idToken || !idToken.length) return undefined
37 | try {
38 | return decodeJWT(idToken)
39 | } catch (e) {
40 | console.warn(`Failed to decode idToken: ${(e as Error).message}`)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | export class FetchError extends Error {
2 | status: number
3 | statusText: string
4 |
5 | constructor(status: number, statusText: string, message: string) {
6 | super(message)
7 | this.name = 'FetchError'
8 | this.status = status
9 | this.statusText = statusText
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/httpUtils.ts:
--------------------------------------------------------------------------------
1 | import { FetchError } from './errors'
2 | import type { TTokenRequest } from './types'
3 |
4 | function buildUrlEncodedRequest(request: TTokenRequest): string {
5 | let queryString = ''
6 | for (const [key, value] of Object.entries(request)) {
7 | queryString += `${queryString ? '&' : ''}${key}=${encodeURIComponent(value)}`
8 | }
9 | return queryString
10 | }
11 |
12 | interface PostWithXFormParams {
13 | url: string
14 | request: TTokenRequest
15 | credentials: RequestCredentials
16 | }
17 |
18 | export async function postWithXForm({ url, request, credentials }: PostWithXFormParams): Promise {
19 | return fetch(url, {
20 | method: 'POST',
21 | body: buildUrlEncodedRequest(request),
22 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
23 | credentials: credentials,
24 | }).then(async (response: Response) => {
25 | if (!response.ok) {
26 | const responseBody = await response.text()
27 | throw new FetchError(response.status, response.statusText, responseBody)
28 | }
29 | return response
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // ##########################################
4 | // NOTE: This file is not part of the package.
5 | // It's only function is to help development in testing and debugging.
6 | // If you want to run the project locally you will need to update the authConfig object with your own auth provider
7 | // ##########################################
8 |
9 | import React, { useContext } from 'react'
10 | import { createRoot } from 'react-dom/client'
11 | import { AuthContext, AuthProvider } from './AuthContext'
12 |
13 | // Get auth provider info from "https://keycloak.ofstad.xyz/realms/master/.well-known/openid-configuration"
14 | /** @type {import('./types').TAuthConfig} */
15 | const authConfig = {
16 | clientId: 'account',
17 | authorizationEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/auth',
18 | tokenEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/token',
19 | logoutEndpoint: 'https://keycloak.ofstad.xyz/realms/master/protocol/openid-connect/logout',
20 | redirectUri: 'http://localhost:5173/',
21 | onRefreshTokenExpire: (event) => event.logIn('', {}, 'popup'),
22 | preLogin: () => console.log('Logging in...'),
23 | postLogin: () => console.log('Logged in!'),
24 | decodeToken: true,
25 | scope: 'profile openid',
26 | // state: 'testState',
27 | clearURL: true,
28 | autoLogin: false,
29 | storage: 'local',
30 | refreshWithScope: false,
31 | }
32 |
33 | function LoginInfo() {
34 | const { tokenData, token, idTokenData, logIn, logOut, error, loginInProgress, idToken } = useContext(AuthContext)
35 |
36 | if (loginInProgress) return null
37 | return (
38 | <>
39 | {error && An error occurred during authentication: {error}
}
40 | <>
41 |
42 |
43 |
44 |
47 | >
48 | {token ? (
49 | <>
50 |
51 |
52 | Access token will expire at:{' '}
53 | {new Date(Number(localStorage.getItem('ROCP_tokenExpire')) * 1000).toLocaleTimeString()}
54 |
55 |
56 |
57 |
Access Token (JWT)
58 |
68 | {token}
69 |
70 |
71 | {authConfig.decodeToken && (
72 | <>
73 |
74 |
Login Information from Access Token
75 |
85 | {JSON.stringify(tokenData, null, 2)}
86 |
87 |
88 |
89 |
Login Information from ID Token
90 |
100 | {JSON.stringify(idTokenData, null, 2)}
101 |
102 |
103 | >
104 | )}
105 |
106 | >
107 | ) : (
108 | You are not logged in
109 | )}
110 | >
111 | )
112 | }
113 |
114 | const container = document.getElementById('root')
115 | if (!container) throw new Error('No container found')
116 | const root = createRoot(container)
117 |
118 | root.render(
119 |
120 |
133 |
134 |
135 |
136 |
137 | )
138 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { AuthProvider, AuthContext } from './AuthContext'
2 | export type {
3 | TAuthConfig,
4 | IAuthProvider,
5 | IAuthContext,
6 | TRefreshTokenExpiredEvent,
7 | } from './types'
8 |
--------------------------------------------------------------------------------
/src/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-oauth2-code-pkce",
3 | "version": "1.23.0",
4 | "description": "Provider agnostic react package for OAuth2 Authorization Code flow with PKCE",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "dependencies": {},
8 | "devDependencies": {
9 | "@types/react": ">=16.8.0",
10 | "typescript": ">=4.4.3"
11 | },
12 | "peerDependencies": {
13 | "react": ">=16.8.0"
14 | },
15 | "scripts": {
16 | "test": "ts-jest",
17 | "start": "yarn run start"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/soofstad/react-oauth2-pkce.git"
22 | },
23 | "keywords": [
24 | "react",
25 | "oauth2",
26 | "pkce",
27 | "code",
28 | "flow",
29 | "azure",
30 | "github",
31 | "keycloak",
32 | "microsoft",
33 | "google",
34 | "fusionauth"
35 | ],
36 | "author": "Stig Oskar Ofstad",
37 | "license": "MIT",
38 | "bugs": {
39 | "url": "https://github.com/soofstad/react-oauth2-pkce/issues"
40 | },
41 | "homepage": "https://github.com/soofstad/react-oauth2-pkce#readme",
42 | "files": ["dist/"]
43 | }
44 |
--------------------------------------------------------------------------------
/src/pkceUtils.ts:
--------------------------------------------------------------------------------
1 | export function getRandomInteger(range: number): number {
2 | const max_range = 256 // Highest possible number in Uint8
3 |
4 | // Create byte array and fill with 1 random number
5 | const byteArray = new Uint8Array(1)
6 | window.crypto.getRandomValues(byteArray) // This is the new, and safer API than Math.Random()
7 |
8 | // If the generated number is out of range, try again
9 | if (byteArray[0] >= Math.floor(max_range / range) * range) return getRandomInteger(range)
10 | return byteArray[0] % range
11 | }
12 |
13 | export function generateRandomString(length: number): string {
14 | let text = ''
15 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
16 | for (let i = 0; i < length; i++) {
17 | text += possible.charAt(getRandomInteger(possible.length - 1))
18 | }
19 | return text
20 | }
21 | /**
22 | * PKCE Code Challenge = base64url(hash(codeVerifier))
23 | */
24 | export async function generateCodeChallenge(codeVerifier: string): Promise {
25 | if (!window.crypto.subtle?.digest) {
26 | throw new Error(
27 | "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details"
28 | )
29 | }
30 | const encoder = new TextEncoder()
31 | const bytes: Uint8Array = encoder.encode(codeVerifier) // Encode the verifier to a byteArray
32 | const hash: ArrayBuffer = await window.crypto.subtle.digest('SHA-256', bytes) // sha256 hash it
33 | const hashString: string = String.fromCharCode(...new Uint8Array(hash))
34 | const base64 = btoa(hashString) // Base64 encode the verifier hash
35 | return base64 // Base64Url encode the base64 encoded string, making it safe as a query param
36 | .replace(/=/g, '')
37 | .replace(/\+/g, '-')
38 | .replace(/\//g, '_')
39 | }
40 |
--------------------------------------------------------------------------------
/src/popupUtils.ts:
--------------------------------------------------------------------------------
1 | import type { TPopupPosition } from './types'
2 |
3 | export function calculatePopupPosition(popupWidth = 600, popupHeight = 600): TPopupPosition {
4 | // Calculate the screen dimensions and position the popup at the center
5 | const screenLeft = window.screenLeft
6 | const screenTop = window.screenTop
7 | const screenWidth = window.innerWidth
8 | const screenHeight = window.innerHeight
9 |
10 | // Calculate the position to center the popup
11 | const defaultLeft = screenLeft + (screenWidth - popupWidth) / 2
12 | const defaultTop = screenTop + (screenHeight - popupHeight) / 2
13 |
14 | // Ensure the bottom-right corner does not go off the screen
15 | // Adjust the left and top positions if necessary
16 | const maxLeft = screenLeft + (screenWidth - popupWidth)
17 | const maxTop = screenTop + (screenHeight - popupHeight)
18 |
19 | return {
20 | width: Math.min(popupWidth, screenWidth),
21 | height: Math.min(popupHeight, screenHeight),
22 | left: Math.max(0, Math.min(defaultLeft, maxLeft)),
23 | top: Math.max(0, Math.min(defaultTop, maxTop)),
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/timeUtils.ts:
--------------------------------------------------------------------------------
1 | import type { TTokenResponse } from './types'
2 | export const FALLBACK_EXPIRE_TIME = 600 // 10minutes
3 |
4 | // Returns epoch time (in seconds) for when the token will expire
5 | // 'secondsFromNow' should always be an integer, but some auth providers has decided that whole numbers should be strings...
6 | export const epochAtSecondsFromNow = (secondsFromNow: number | string) =>
7 | Math.round(Date.now() / 1000 + Number(secondsFromNow))
8 |
9 | /**
10 | * Check if the Access Token has expired.
11 | * Will return True if the token has expired, OR there is less than 30 seconds until it expires.
12 | */
13 | export function epochTimeIsPast(timestamp: number): boolean {
14 | const now = Math.round(Date.now()) / 1000
15 | const nowWithBuffer = now + 30
16 | return nowWithBuffer >= timestamp
17 | }
18 |
19 | const refreshExpireKeys = [
20 | 'refresh_expires_in', // KeyCloak
21 | 'refresh_token_expires_in', // Azure AD
22 | ] as const
23 |
24 | export function getRefreshExpiresIn(tokenExpiresIn: number, response: TTokenResponse): number {
25 | for (const key of refreshExpireKeys) {
26 | if (key in response) return response[key] as number
27 | }
28 | // If the response has a refresh_token, but no expire_time. Assume it's at least 10m longer than access_token's expire
29 | if (response.refresh_token) return tokenExpiresIn + FALLBACK_EXPIRE_TIME
30 | // The token response had no refresh_token. Set refresh_expire equals to access_token expire
31 | return tokenExpiresIn
32 | }
33 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "commonjs",
5 | "lib": ["ESNext", "DOM"],
6 | "allowJs": false,
7 | "jsx": "react",
8 | "declaration": true,
9 | "outDir": "dist",
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "skipLibCheck": true,
14 | "types": []
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 |
3 | // Makes only the specified keys required in the provided type
4 | // Source: https://www.emmanuelgautier.com/blog/snippets/typescript-required-properties
5 | type WithRequired = T & { [P in K]-?: T[P] }
6 |
7 | interface TTokenRqBase {
8 | grant_type: string
9 | client_id: string
10 | redirect_uri: string
11 | }
12 |
13 | export interface TTokenRequestWithCodeAndVerifier extends TTokenRqBase {
14 | code: string
15 | code_verifier: string
16 | }
17 |
18 | export interface TTokenRequestForRefresh extends TTokenRqBase {
19 | scope?: string
20 | refresh_token: string
21 | }
22 |
23 | export type TTokenRequest = TTokenRequestWithCodeAndVerifier | TTokenRequestForRefresh
24 |
25 | export type TTokenData = {
26 | // biome-ignore lint: It really can be `any` (almost)
27 | [x: string]: any
28 | }
29 |
30 | export type TTokenResponse = {
31 | access_token: string
32 | scope: string
33 | token_type: string
34 | expires_in?: number
35 | refresh_token?: string
36 | refresh_token_expires_in?: number
37 | refresh_expires_in?: number
38 | id_token?: string
39 | }
40 |
41 | export type TLoginMethod = 'redirect' | 'replace' | 'popup'
42 |
43 | export type TPopupPosition = {
44 | left: number
45 | top: number
46 | width: number
47 | height: number
48 | }
49 |
50 | export interface IAuthProvider {
51 | authConfig: TAuthConfig
52 | children: ReactNode
53 | }
54 |
55 | type TLogInFunction = (state?: string, additionalParameters?: TPrimitiveRecord, method?: TLoginMethod) => void
56 | export interface IAuthContext {
57 | token: string
58 | logIn: TLogInFunction
59 | logOut: (state?: string, logoutHint?: string, additionalParameters?: TPrimitiveRecord) => void
60 | /** @deprecated Use `logIn` instead */
61 | login: TLogInFunction
62 | error: string | null
63 | tokenData?: TTokenData
64 | idToken?: string
65 | idTokenData?: TTokenData
66 | loginInProgress: boolean
67 | }
68 |
69 | export type TPrimitiveRecord = { [key: string]: string | boolean | number }
70 |
71 | // Input from users of the package, some optional values
72 | export type TAuthConfig = {
73 | clientId: string
74 | authorizationEndpoint: string
75 | tokenEndpoint: string
76 | redirectUri: string
77 | scope?: string
78 | state?: string
79 | logoutEndpoint?: string
80 | logoutRedirect?: string
81 | preLogin?: () => void
82 | postLogin?: () => void
83 | loginMethod?: TLoginMethod
84 | onRefreshTokenExpire?: (event: TRefreshTokenExpiredEvent) => void
85 | decodeToken?: boolean
86 | autoLogin?: boolean
87 | clearURL?: boolean
88 | /** @deprecated Use `extraAuthParameters` instead. Will be removed in a future version. */
89 | extraAuthParams?: TPrimitiveRecord
90 | extraAuthParameters?: TPrimitiveRecord
91 | extraTokenParameters?: TPrimitiveRecord
92 | extraLogoutParameters?: TPrimitiveRecord
93 | tokenExpiresIn?: number
94 | refreshTokenExpiresIn?: number
95 | refreshTokenExpiryStrategy?: 'renewable' | 'absolute'
96 | storage?: 'session' | 'local'
97 | storageKeyPrefix?: string
98 | refreshWithScope?: boolean
99 | tokenRequestCredentials?: RequestCredentials
100 | }
101 |
102 | export type TRefreshTokenExpiredEvent = {
103 | logIn: TLogInFunction
104 | /** @deprecated Use `logIn` instead. Will be removed in a future version. */
105 | login: TLogInFunction
106 | }
107 |
108 | // The AuthProviders internal config type. All values will be set by user provided, or default values
109 | export type TInternalConfig = WithRequired<
110 | TAuthConfig,
111 | | 'loginMethod'
112 | | 'decodeToken'
113 | | 'autoLogin'
114 | | 'clearURL'
115 | | 'refreshTokenExpiryStrategy'
116 | | 'storage'
117 | | 'storageKeyPrefix'
118 | | 'refreshWithScope'
119 | | 'tokenRequestCredentials'
120 | >
121 |
--------------------------------------------------------------------------------
/tests/auth-util.test.ts:
--------------------------------------------------------------------------------
1 | import { fetchWithRefreshToken } from '../src/authentication'
2 | import { decodeJWT } from '../src/decodeJWT'
3 | import { FetchError } from '../src/errors'
4 | import { epochAtSecondsFromNow, epochTimeIsPast } from '../src/timeUtils'
5 | import type { TInternalConfig } from '../src/types'
6 |
7 | const authConfig: TInternalConfig = {
8 | autoLogin: false,
9 | decodeToken: false,
10 | clientId: 'myClientID',
11 | authorizationEndpoint: 'myAuthEndpoint',
12 | tokenEndpoint: 'myTokenEndpoint',
13 | redirectUri: 'http://localhost:5173/',
14 | scope: 'someScope openid',
15 | clearURL: false,
16 | storage: 'local',
17 | refreshTokenExpiryStrategy: 'renewable',
18 | storageKeyPrefix: 'ROCP_',
19 | refreshWithScope: true,
20 | loginMethod: 'redirect',
21 | extraAuthParams: {
22 | prompt: true,
23 | client_id: 'anotherClientId',
24 | },
25 | extraTokenParameters: {
26 | prompt: true,
27 | client_id: 'anotherClientId',
28 | testKey: 'test Value',
29 | },
30 | tokenRequestCredentials: 'same-origin',
31 | }
32 |
33 | test('decode a JWT token', () => {
34 | const tokenData = decodeJWT(
35 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Sfl'
36 | )
37 | expect(tokenData?.name).toBe('John Doe')
38 | })
39 |
40 | test('decode a non-JWT token', () => {
41 | console.error = jest.fn()
42 | expect(() => {
43 | decodeJWT('somethingStringWhateverThis is not a JWT')
44 | }).toThrow()
45 | })
46 |
47 | test('check if expired token has expired', () => {
48 | const willExpireAt = epochAtSecondsFromNow(-5) // Expired 5 seconds ago
49 | const hasExpired = epochTimeIsPast(willExpireAt)
50 | expect(hasExpired).toBe(true)
51 | })
52 |
53 | test('check if still valid token inside buffer has expired', () => {
54 | const willExpireAt = epochAtSecondsFromNow(5) // Will expire in 5 seconds
55 | const hasExpired = epochTimeIsPast(willExpireAt)
56 | expect(hasExpired).toBe(true)
57 | })
58 |
59 | test('expire time as string gets correctly converted', () => {
60 | const expectedEpoch = Math.round(Date.now() / 1000 + 55555)
61 | const epochSumCalculated = epochAtSecondsFromNow('55555')
62 | expect(expectedEpoch).toBe(epochSumCalculated)
63 | })
64 |
65 | test('expire time as int gets correctly converted', () => {
66 | const expectedEpoch = Math.round(Date.now() / 1000 + 55555)
67 | const epochSumCalculated = epochAtSecondsFromNow(55555)
68 | expect(expectedEpoch).toBe(epochSumCalculated)
69 | })
70 |
71 | test('check if still valid token outside buffer has expired', () => {
72 | const willExpireAt = epochAtSecondsFromNow(301) // Will expire in 5min
73 | const hasExpired = epochTimeIsPast(willExpireAt)
74 | expect(hasExpired).toBe(false)
75 | })
76 |
77 | test('failed refresh fetch raises FetchError', () => {
78 | // @ts-ignore
79 | global.fetch = jest.fn(() =>
80 | Promise.resolve({
81 | ok: false,
82 | status: 400,
83 | statusText: 'Bad request',
84 | text: async () => 'Failed to refresh token error body',
85 | })
86 | )
87 | fetchWithRefreshToken({ config: authConfig, refreshToken: '' }).catch((error: unknown) => {
88 | if (error instanceof FetchError) {
89 | expect(error.status).toBe(400)
90 | expect(error.message).toBe('Failed to refresh token error body')
91 | } else {
92 | throw new Error('This is the wrong error type')
93 | }
94 | })
95 | })
96 |
--------------------------------------------------------------------------------
/tests/get_token.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, waitFor } from '@testing-library/react'
2 | import React from 'react'
3 | import { AuthProvider } from '../src'
4 | import type { TTokenResponse } from '../src/types'
5 | import { AuthConsumer, authConfig } from './test-utils'
6 |
7 | // @ts-ignore
8 | global.fetch = jest.fn(() =>
9 | Promise.resolve({
10 | ok: true,
11 | json: () =>
12 | Promise.resolve({
13 | scope: 'value',
14 | refresh_token: '1234',
15 | token_type: 'dummy',
16 | access_token:
17 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Sfl',
18 | }),
19 | })
20 | )
21 |
22 | describe('make token request', () => {
23 | beforeEach(() => {
24 | // Setting up a state similar to what it would be just after redirect back from auth provider
25 | localStorage.setItem('ROCP_loginInProgress', 'true')
26 | localStorage.setItem('PKCE_code_verifier', 'arandomstring')
27 | window.location.search = '?code=1234'
28 | })
29 |
30 | test('with extra parameters', async () => {
31 | render(
32 |
33 |
34 |
35 | )
36 |
37 | await waitFor(() =>
38 | expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', {
39 | body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue',
40 | headers: {
41 | 'Content-Type': 'application/x-www-form-urlencoded',
42 | },
43 | method: 'POST',
44 | credentials: 'same-origin',
45 | })
46 | )
47 | })
48 |
49 | test('with custom credentials', async () => {
50 | render(
51 |
52 |
53 |
54 | )
55 |
56 | await waitFor(() =>
57 | expect(fetch).toHaveBeenCalledWith('myTokenEndpoint', {
58 | body: 'grant_type=authorization_code&code=1234&client_id=anotherClientId&redirect_uri=http%3A%2F%2Flocalhost%2F&code_verifier=arandomstring&testTokenKey=tokenValue',
59 | headers: {
60 | 'Content-Type': 'application/x-www-form-urlencoded',
61 | },
62 | method: 'POST',
63 | credentials: 'include',
64 | })
65 | )
66 | })
67 | })
68 |
--------------------------------------------------------------------------------
/tests/jestSetup.js:
--------------------------------------------------------------------------------
1 | const { TextDecoder, TextEncoder } = require('node:util')
2 | const nodeCrypto = require('node:crypto')
3 |
4 | beforeEach(() => {
5 | localStorage.removeItem('ROCP_loginInProgress')
6 | localStorage.removeItem('ROCP_token')
7 | localStorage.removeItem('ROCP_refreshToken')
8 | localStorage.removeItem('PKCE_code_verifier')
9 |
10 | global.TextEncoder = TextEncoder
11 | global.TextDecoder = TextDecoder
12 |
13 | global.crypto.subtle = nodeCrypto.webcrypto.subtle
14 |
15 | // biome-ignore lint: set undefine does not work...
16 | delete window.location
17 | const location = new URL('https://www.example.com')
18 | location.assign = jest.fn()
19 | window.location = location
20 | window.open = jest.fn()
21 | })
22 |
--------------------------------------------------------------------------------
/tests/login.test.tsx:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom'
2 | import { render, screen, waitFor } from '@testing-library/react'
3 | import React from 'react'
4 | import { AuthProvider } from '../src'
5 | import { AuthConsumer, authConfig } from './test-utils'
6 |
7 | test('First page visit should redirect to auth provider for login', async () => {
8 | render(
9 |
10 |
11 |
12 | )
13 |
14 | await waitFor(() => {
15 | expect(window.location.assign).toHaveBeenCalledWith(
16 | expect.stringMatching(
17 | /^myAuthEndpoint\?response_type=code&client_id=myClientID&redirect_uri=http%3A%2F%2Flocalhost%2F&code_challenge=.{43}&code_challenge_method=S256&scope=someScope\+openid&state=testState/gm
18 | )
19 | )
20 | })
21 | })
22 |
23 | test('First page visit should popup to auth provider for login', async () => {
24 | // set window size to 1200x800 to make test predictable in different environments
25 | global.innerWidth = 1200
26 | global.innerHeight = 800
27 | render(
28 |
29 |
30 |
31 | )
32 |
33 | await waitFor(() => {
34 | expect(window.open).toHaveBeenCalledWith(
35 | expect.stringMatching(
36 | /^myAuthEndpoint\?response_type=code&client_id=myClientID&redirect_uri=http%3A%2F%2Flocalhost%2F&code_challenge=.{43}&code_challenge_method=S256&scope=someScope\+openid&state=testState/gm
37 | ),
38 | 'loginPopup',
39 | 'width=600,height=600,top=100,left=300'
40 | )
41 | })
42 | })
43 |
44 | test('Attempting to log in with an unsecure context should raise error', async () => {
45 | // @ts-ignore
46 | window.crypto.subtle.digest = undefined
47 | render(
48 |
49 |
50 |
51 | )
52 |
53 | const errorNode = await waitFor(() => screen.findByLabelText('error'))
54 |
55 | expect(errorNode).toHaveTextContent(
56 | "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details"
57 | )
58 | expect(screen.getByLabelText('loginInProgress')).toHaveTextContent('false')
59 | })
60 |
--------------------------------------------------------------------------------
/tests/logout.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor } from '@testing-library/react'
2 | import userEvent from '@testing-library/user-event'
3 | import React from 'react'
4 | import { AuthProvider } from '../src'
5 | import { AuthConsumer, authConfig } from './test-utils'
6 |
7 | test('Full featured logout requests', async () => {
8 | localStorage.setItem('ROCP_loginInProgress', 'false')
9 | localStorage.setItem('ROCP_token', '"test-token-value"')
10 | localStorage.setItem('ROCP_refreshToken', '"test-refresh-value"')
11 | const user = userEvent.setup()
12 |
13 | render(
14 |
15 |
16 |
17 | )
18 |
19 | await user.click(screen.getByText('Log out'))
20 |
21 | await waitFor(() =>
22 | expect(window.location.assign).toHaveBeenCalledWith(
23 | 'myLogoutEndpoint?token=test-refresh-value&token_type_hint=refresh_token&client_id=myClientID&post_logout_redirect_uri=primary-logout-redirect&ui_locales=en-US+en&testLogoutKey=logoutValue&state=logoutState'
24 | )
25 | )
26 | expect(window.location.assign).toHaveBeenCalledTimes(1)
27 | })
28 |
29 | test('No refresh token, no logoutRedirect, logout request', async () => {
30 | localStorage.setItem('ROCP_loginInProgress', 'false')
31 | localStorage.setItem('ROCP_token', '"test-token-value"')
32 | authConfig.logoutRedirect = undefined
33 | const user = userEvent.setup()
34 |
35 | render(
36 |
37 |
38 |
39 | )
40 |
41 | await user.click(screen.getByText('Log out'))
42 |
43 | await waitFor(() =>
44 | expect(window.location.assign).toHaveBeenCalledWith(
45 | 'myLogoutEndpoint?token=test-token-value&token_type_hint=access_token&client_id=myClientID&post_logout_redirect_uri=http%3A%2F%2Flocalhost%2F&ui_locales=en-US+en&testLogoutKey=logoutValue&state=logoutState'
46 | )
47 | )
48 | expect(window.location.assign).toHaveBeenCalledTimes(1)
49 | })
50 |
--------------------------------------------------------------------------------
/tests/test-utils.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { AuthContext, type TAuthConfig } from '../src'
3 |
4 | export const authConfig: TAuthConfig = {
5 | autoLogin: true,
6 | clientId: 'myClientID',
7 | authorizationEndpoint: 'myAuthEndpoint',
8 | tokenEndpoint: 'myTokenEndpoint',
9 | logoutEndpoint: 'myLogoutEndpoint',
10 | redirectUri: 'http://localhost/',
11 | logoutRedirect: 'primary-logout-redirect',
12 | scope: 'someScope openid',
13 | decodeToken: false,
14 | state: 'testState',
15 | loginMethod: 'redirect',
16 | extraLogoutParameters: {
17 | testLogoutKey: 'logoutValue',
18 | },
19 | extraAuthParams: {
20 | client_id: 'anotherClientId',
21 | },
22 | extraTokenParameters: {
23 | testTokenKey: 'tokenValue',
24 | },
25 | }
26 |
27 | export const AuthConsumer = () => {
28 | const { tokenData, logOut, loginInProgress, idToken, idTokenData, logIn, token, error } = useContext(AuthContext)
29 | return (
30 | <>
31 | {tokenData?.name}
32 |
35 |
38 |
39 |
40 |
41 | >
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./src/**/*"],
3 | "compilerOptions": {
4 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
6 | "lib": [
7 | "es2016",
8 | "dom"
9 | ] /* Specify library files to be included in the compilation. */,
10 | "allowJs": false /* Allow javascript files to be compiled. */,
11 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
12 | "declaration": true /* Generates corresponding '.d.ts' file. */,
13 | "outDir": "dist" /* Redirect output structure to the directory. */,
14 | "strict": true /* Enable all strict type-checking options. */,
15 | "esModuleInterop": true,
16 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | })
7 |
--------------------------------------------------------------------------------