react-oauth-providers 1.0.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/README.md +118 -0
- package/package.json +30 -0
- package/src/AuthContext.tsx +4 -0
- package/src/AuthProvider.tsx +103 -0
- package/src/actions.ts +156 -0
- package/src/authConfig.ts +56 -0
- package/src/google/index.ts +157 -0
- package/src/index.ts +5 -0
- package/src/types.ts +57 -0
- package/src/useAuth.tsx +13 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/http.ts +51 -0
- package/src/utils/others.ts +13 -0
- package/src/utils/pkce.ts +15 -0
- package/src/utils/session.ts +33 -0
- package/src/utils/storage.ts +18 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# React OAuth Providers
|
|
2
|
+
|
|
3
|
+
A lightweight React package for OAuth authentication. Currently, only **Google** is supported.
|
|
4
|
+
|
|
5
|
+
## ⚠️ **Notes**
|
|
6
|
+
|
|
7
|
+
- This package is designed for **SPA applications**.
|
|
8
|
+
- Google **UWP clients** are supported.
|
|
9
|
+
- Passing a **client secret** from the frontend is **vulnerable** and should be avoided.
|
|
10
|
+
- Configure **spaCallbackUri** correctly to match your route.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<br>
|
|
15
|
+
<br>
|
|
16
|
+
|
|
17
|
+
## 📦 Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install react-oauth-providers@latest
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
<br>
|
|
24
|
+
<br>
|
|
25
|
+
|
|
26
|
+
## ⚙️ Setup Google OAuth
|
|
27
|
+
|
|
28
|
+
Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new app.
|
|
29
|
+
|
|
30
|
+
Create an OAuth client and set **Application type** as **Universal Windows Platform (UWP)**.
|
|
31
|
+
|
|
32
|
+
<br>
|
|
33
|
+
<br>
|
|
34
|
+
|
|
35
|
+
## 🚀 Quick Start
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { StrictMode } from 'react'
|
|
39
|
+
import { createRoot } from 'react-dom/client'
|
|
40
|
+
import { AuthProvider, type IAuthConfig } from 'react-oauth-providers'
|
|
41
|
+
import App from './App'
|
|
42
|
+
|
|
43
|
+
const authConfig: IAuthConfig = {
|
|
44
|
+
googleProvider: {
|
|
45
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
46
|
+
},
|
|
47
|
+
storage: 'session', // 'session' | 'local'
|
|
48
|
+
spaCallbackUri: `${window.location.origin}/auth/callback`,
|
|
49
|
+
preLogin: () => console.log('Before login...'),
|
|
50
|
+
postLogin: async () => {
|
|
51
|
+
console.log('After login...')
|
|
52
|
+
window.location.href = window.location.origin
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
createRoot(document.getElementById('root')!).render(
|
|
57
|
+
<StrictMode>
|
|
58
|
+
<AuthProvider authConfig={authConfig}>
|
|
59
|
+
<App />
|
|
60
|
+
</AuthProvider>
|
|
61
|
+
</StrictMode>
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
<br>
|
|
66
|
+
<br>
|
|
67
|
+
|
|
68
|
+
## 🔑 Using Authentication in Components
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { useAuth } from "@/providers/Auth";
|
|
72
|
+
|
|
73
|
+
export default function Login() {
|
|
74
|
+
const { loginInProgress, user, logOut, signinWithGoogle } = useAuth();
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
{user ? (
|
|
79
|
+
<>
|
|
80
|
+
<p>Welcome, {user.name}</p>
|
|
81
|
+
<button onClick={logOut}>Logout</button>
|
|
82
|
+
</>
|
|
83
|
+
) : (
|
|
84
|
+
<button
|
|
85
|
+
onClick={async () => await signinWithGoogle()}
|
|
86
|
+
disabled={loginInProgress}
|
|
87
|
+
>
|
|
88
|
+
{loginInProgress ? "Signing in..." : "Sign in with Google"}
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
<br>
|
|
97
|
+
<br>
|
|
98
|
+
|
|
99
|
+
## ✅ Features
|
|
100
|
+
|
|
101
|
+
- **Google OAuth** (no backend secret required)
|
|
102
|
+
- **Session or Local storage** support
|
|
103
|
+
- **preLogin** & **postLogin** hooks for custom logic
|
|
104
|
+
- **Automatic token management**: token storage, decoding, and refresh are fully handled by the package
|
|
105
|
+
- Fully **React hooks-based** API (useAuth)
|
|
106
|
+
|
|
107
|
+
<br>
|
|
108
|
+
|
|
109
|
+
## 🧩 AuthProvider Props
|
|
110
|
+
|
|
111
|
+
| Prop | Type | Description |
|
|
112
|
+
| -------------- | -------------------------------------- | --------------------------------------------------------- |
|
|
113
|
+
| googleProvider | `{ clientId: string, scope?: string }` | Your Google OAuth client configuration |
|
|
114
|
+
| storage | `"session" \| "local"` | Storage type for session or local storage |
|
|
115
|
+
| spaCallbackUri | `string` | SPA callback URL for OAuth redirects |
|
|
116
|
+
| preLogin | `() => void` | Function called before login starts |
|
|
117
|
+
| postLogin | `() => void \| Promise<void>` | Function called after login completes |
|
|
118
|
+
| onLogInError | `(error) => void` | Function called when an error occurs in the login process |
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-oauth-providers",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.cjs.js",
|
|
5
|
+
"module": "dist/index.esm.js",
|
|
6
|
+
"types": "dist/types/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup src/index.tsx --format cjs,esm --dts --out-dir dist",
|
|
9
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"oauth-pkce": "^0.0.7"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"react": "^18.0.0",
|
|
16
|
+
"react-dom": "^18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/react": "^18.0.0",
|
|
20
|
+
"@types/react-dom": "^18.0.0",
|
|
21
|
+
"tsup": "^7.0.0",
|
|
22
|
+
"typescript": "^5.0.0"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"oauth-providers",
|
|
26
|
+
"react-oauth-providers",
|
|
27
|
+
"google-oauth-provider"
|
|
28
|
+
],
|
|
29
|
+
"author": "Asif Sorowar"
|
|
30
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { AuthContext } from "./AuthContext";
|
|
3
|
+
import * as actions from "./actions";
|
|
4
|
+
import type { IAuthProvider, ILogoutFunc, IAuthInternalConfig } from "./types";
|
|
5
|
+
import { createInternalConfig } from "./authConfig";
|
|
6
|
+
import { getSessionItem } from "./utils/session";
|
|
7
|
+
|
|
8
|
+
export const AuthProvider: React.FC<IAuthProvider> = ({
|
|
9
|
+
authConfig,
|
|
10
|
+
children,
|
|
11
|
+
}) => {
|
|
12
|
+
const config = useMemo<IAuthInternalConfig>(
|
|
13
|
+
() => createInternalConfig(authConfig),
|
|
14
|
+
[authConfig]
|
|
15
|
+
);
|
|
16
|
+
const tmpAuthProviderType = useMemo(
|
|
17
|
+
() => getSessionItem(config.keys.PROVIDER_TYPE),
|
|
18
|
+
[config]
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const [loginInProgress, setLoginInProgress] = useState(true);
|
|
22
|
+
const [user, setUser] = useState<null | any>();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
actions.handleFetchSessionUser().then((user) => {
|
|
26
|
+
setUser(user);
|
|
27
|
+
setLoginInProgress(false);
|
|
28
|
+
});
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const signinWithGoogle = useCallback(async () => {
|
|
32
|
+
if (!config.googleProvider?.clientId) {
|
|
33
|
+
throw Error(
|
|
34
|
+
"'clientId' must be set in the 'googleProvider' object in the AuthProvider Config"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
config.preLogin?.();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (config.googleProvider.type === "spa") {
|
|
42
|
+
await actions.signinWithGoogleSpa();
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
config.onLogInError?.(error);
|
|
46
|
+
}
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// callback handler for spa login
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!config || !tmpAuthProviderType) return;
|
|
52
|
+
if (window.location.pathname !== new URL(config.spaCallbackUri!).pathname) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const callback = async () => {
|
|
57
|
+
try {
|
|
58
|
+
await actions.handleCallback();
|
|
59
|
+
|
|
60
|
+
config.postLogin?.();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
config.onLogInError?.(error);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
callback();
|
|
67
|
+
}, [tmpAuthProviderType, config]);
|
|
68
|
+
|
|
69
|
+
const logOut = useCallback<ILogoutFunc>(
|
|
70
|
+
({ logoutHint, redirect = true, redirectUri } = {}) => {
|
|
71
|
+
actions.logout();
|
|
72
|
+
|
|
73
|
+
if (!redirect) return;
|
|
74
|
+
|
|
75
|
+
// Determine redirect URL or fallback to root
|
|
76
|
+
const baseUrl = redirectUri || config.logoutRedirect || "/";
|
|
77
|
+
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
if (logoutHint) params.append("logout_hint", logoutHint);
|
|
80
|
+
|
|
81
|
+
// Append params only if there are any
|
|
82
|
+
const url = params.toString()
|
|
83
|
+
? `${baseUrl}?${params.toString()}`
|
|
84
|
+
: baseUrl;
|
|
85
|
+
|
|
86
|
+
window.location.assign(url);
|
|
87
|
+
},
|
|
88
|
+
[]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<AuthContext.Provider
|
|
93
|
+
value={{
|
|
94
|
+
loginInProgress,
|
|
95
|
+
signinWithGoogle,
|
|
96
|
+
user,
|
|
97
|
+
logOut,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</AuthContext.Provider>
|
|
102
|
+
);
|
|
103
|
+
};
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { getSessionItem } from "./utils/session";
|
|
2
|
+
import {
|
|
3
|
+
getGoogleSessionUser,
|
|
4
|
+
signinWithGoogleCallback,
|
|
5
|
+
refreshGoogleAccessToken,
|
|
6
|
+
} from "./google";
|
|
7
|
+
import { getConfig } from "./authConfig";
|
|
8
|
+
import { AUTH_PROVIDERS } from "./utils/constants";
|
|
9
|
+
import { getFromStorage, removeFromStorage } from "./utils/storage";
|
|
10
|
+
|
|
11
|
+
export { signinWithGoogleSpa as signinWithGoogleSpa } from "./google";
|
|
12
|
+
|
|
13
|
+
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
14
|
+
|
|
15
|
+
export const handleCallback = async () => {
|
|
16
|
+
const config = getConfig();
|
|
17
|
+
if (!config) return;
|
|
18
|
+
|
|
19
|
+
const keys = config?.keys;
|
|
20
|
+
|
|
21
|
+
const authMode = getSessionItem(keys.PROVIDER);
|
|
22
|
+
const verifier = getSessionItem(keys.VERIFIER);
|
|
23
|
+
if (!authMode || !verifier) return;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
switch (authMode) {
|
|
27
|
+
case AUTH_PROVIDERS.google: {
|
|
28
|
+
const searchQuery = new URLSearchParams(window.location.search);
|
|
29
|
+
const code = searchQuery.get("code");
|
|
30
|
+
if (!code) return;
|
|
31
|
+
|
|
32
|
+
await signinWithGoogleCallback(code, verifier);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
default:
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return Promise.reject(error);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const handleFetchSessionUser = async (retry = true) => {
|
|
45
|
+
const config = getConfig();
|
|
46
|
+
if (!config) return;
|
|
47
|
+
|
|
48
|
+
const keys = config?.keys;
|
|
49
|
+
|
|
50
|
+
const authMode = getFromStorage(config.storage!, keys.PROVIDER);
|
|
51
|
+
if (!authMode) return;
|
|
52
|
+
|
|
53
|
+
let sessionUser = null;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
switch (authMode) {
|
|
57
|
+
case AUTH_PROVIDERS.google: {
|
|
58
|
+
sessionUser = await getGoogleSessionUser();
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (sessionUser) handleScheduleTokenRefresh();
|
|
67
|
+
|
|
68
|
+
return sessionUser;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.log("Fetch Session User Error:", error);
|
|
71
|
+
|
|
72
|
+
if (!retry) return null;
|
|
73
|
+
|
|
74
|
+
switch (authMode) {
|
|
75
|
+
case AUTH_PROVIDERS.google: {
|
|
76
|
+
await refreshGoogleAccessToken();
|
|
77
|
+
await handleFetchSessionUser(false);
|
|
78
|
+
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
default:
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleScheduleTokenRefresh = () => {
|
|
91
|
+
const config = getConfig();
|
|
92
|
+
if (!config) return;
|
|
93
|
+
|
|
94
|
+
const keys = config?.keys;
|
|
95
|
+
|
|
96
|
+
const authMode = getFromStorage(config.storage!, keys.PROVIDER);
|
|
97
|
+
if (!authMode) return;
|
|
98
|
+
|
|
99
|
+
const expiresIn = Number(
|
|
100
|
+
getFromStorage(config.storage!, keys.EXPIRES_IN) ?? 0
|
|
101
|
+
);
|
|
102
|
+
if (!expiresIn) return;
|
|
103
|
+
|
|
104
|
+
clearTokenRefresh();
|
|
105
|
+
|
|
106
|
+
// Refresh 60 seconds before expiry
|
|
107
|
+
const refreshTime = expiresIn - 60 * 1000;
|
|
108
|
+
refreshTimeout = setTimeout(async () => {
|
|
109
|
+
switch (authMode) {
|
|
110
|
+
case AUTH_PROVIDERS.google: {
|
|
111
|
+
await refreshGoogleAccessToken();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
default:
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}, refreshTime);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const clearTokenRefresh = () => {
|
|
122
|
+
if (refreshTimeout) {
|
|
123
|
+
clearTimeout(refreshTimeout);
|
|
124
|
+
refreshTimeout = null;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const logout = () => {
|
|
129
|
+
clearTokens();
|
|
130
|
+
clearTokenRefresh();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const clearTokens = () => {
|
|
134
|
+
const config = getConfig();
|
|
135
|
+
if (!config) return;
|
|
136
|
+
|
|
137
|
+
const keys = config?.keys;
|
|
138
|
+
|
|
139
|
+
const {
|
|
140
|
+
PROVIDER,
|
|
141
|
+
ACCESS_TOKEN,
|
|
142
|
+
EXPIRES_IN,
|
|
143
|
+
REFRESH_TOKEN,
|
|
144
|
+
TOKEN_TYPE,
|
|
145
|
+
ID_TOKEN,
|
|
146
|
+
PROVIDER_TYPE,
|
|
147
|
+
} = keys;
|
|
148
|
+
|
|
149
|
+
removeFromStorage(config.storage!, PROVIDER);
|
|
150
|
+
removeFromStorage(config.storage!, PROVIDER_TYPE);
|
|
151
|
+
removeFromStorage(config.storage!, ACCESS_TOKEN);
|
|
152
|
+
removeFromStorage(config.storage!, EXPIRES_IN);
|
|
153
|
+
removeFromStorage(config.storage!, REFRESH_TOKEN);
|
|
154
|
+
removeFromStorage(config.storage!, TOKEN_TYPE);
|
|
155
|
+
removeFromStorage(config.storage!, ID_TOKEN);
|
|
156
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IAuthConfig, IAuthInternalConfig, Keys } from "./types";
|
|
2
|
+
import { toURL, makeKey } from "./utils/others";
|
|
3
|
+
|
|
4
|
+
export let config: IAuthInternalConfig | null = null;
|
|
5
|
+
|
|
6
|
+
export const getConfig = () => config;
|
|
7
|
+
|
|
8
|
+
export function createInternalConfig(
|
|
9
|
+
passedConfig: IAuthConfig
|
|
10
|
+
): IAuthInternalConfig {
|
|
11
|
+
// Set default values for internal config object
|
|
12
|
+
const {
|
|
13
|
+
preLogin = () => null,
|
|
14
|
+
postLogin = () => null,
|
|
15
|
+
onLogInError = () => null,
|
|
16
|
+
storage = "local",
|
|
17
|
+
spaCallbackUri = passedConfig.spaCallbackUri
|
|
18
|
+
? toURL(passedConfig.spaCallbackUri).toString()
|
|
19
|
+
: window.location.origin,
|
|
20
|
+
logoutRedirect = passedConfig.logoutRedirect
|
|
21
|
+
? toURL(passedConfig.logoutRedirect).toString()
|
|
22
|
+
: window.location.origin,
|
|
23
|
+
storageKeyPrefix = "auth_",
|
|
24
|
+
googleProvider,
|
|
25
|
+
}: IAuthConfig = passedConfig;
|
|
26
|
+
|
|
27
|
+
config = {
|
|
28
|
+
...passedConfig,
|
|
29
|
+
preLogin,
|
|
30
|
+
postLogin,
|
|
31
|
+
onLogInError,
|
|
32
|
+
storage,
|
|
33
|
+
storageKeyPrefix,
|
|
34
|
+
spaCallbackUri,
|
|
35
|
+
logoutRedirect,
|
|
36
|
+
keys: createAuthKeys(storageKeyPrefix),
|
|
37
|
+
googleProvider: googleProvider
|
|
38
|
+
? { type: "spa", scope: "openid email profile", ...googleProvider }
|
|
39
|
+
: undefined,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return config;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createAuthKeys(prefix?: string): Record<keyof Keys, string> {
|
|
46
|
+
return {
|
|
47
|
+
PROVIDER_TYPE: makeKey("provider_type", prefix),
|
|
48
|
+
PROVIDER: makeKey("provider", prefix),
|
|
49
|
+
VERIFIER: makeKey("verifier", prefix),
|
|
50
|
+
ACCESS_TOKEN: makeKey("access_token", prefix),
|
|
51
|
+
EXPIRES_IN: makeKey("expires_in", prefix),
|
|
52
|
+
REFRESH_TOKEN: makeKey("refresh_token", prefix),
|
|
53
|
+
TOKEN_TYPE: makeKey("token_type", prefix),
|
|
54
|
+
ID_TOKEN: makeKey("id_token", prefix),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import http from "../utils/http";
|
|
2
|
+
import { removeSessionItem, setSessionItem } from "../utils/session";
|
|
3
|
+
import { generatePkce } from "../utils/pkce";
|
|
4
|
+
import type { IAuthGoogleProviderType } from "../types";
|
|
5
|
+
import { getConfig } from "../authConfig";
|
|
6
|
+
import { AUTH_PROVIDERS } from "../utils/constants";
|
|
7
|
+
import { getFromStorage, keepToStorage } from "../utils/storage";
|
|
8
|
+
|
|
9
|
+
export const signinWithGoogleSpa = async () => {
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
if (!config) return;
|
|
12
|
+
|
|
13
|
+
const { googleProvider, keys } = config;
|
|
14
|
+
|
|
15
|
+
const { verifier, challenge, algorithm } = await generatePkce();
|
|
16
|
+
|
|
17
|
+
setSessionItem(keys.VERIFIER, verifier, 3 * 60 * 1000);
|
|
18
|
+
setSessionItem(keys.PROVIDER, AUTH_PROVIDERS.google, 3 * 60 * 1000);
|
|
19
|
+
setSessionItem(keys.PROVIDER_TYPE, googleProvider?.type!, 3 * 60 * 1000);
|
|
20
|
+
|
|
21
|
+
const params = new URLSearchParams({
|
|
22
|
+
client_id: googleProvider?.clientId!,
|
|
23
|
+
redirect_uri: config?.spaCallbackUri!,
|
|
24
|
+
response_type: "code",
|
|
25
|
+
scope: googleProvider?.scope || "",
|
|
26
|
+
code_challenge: challenge,
|
|
27
|
+
code_challenge_method: algorithm,
|
|
28
|
+
access_type: "offline",
|
|
29
|
+
prompt: "consent",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const signinWithGoogleCallback = async (
|
|
36
|
+
code: string,
|
|
37
|
+
verifier: string
|
|
38
|
+
) => {
|
|
39
|
+
if (!code || !verifier) return;
|
|
40
|
+
|
|
41
|
+
const config = getConfig();
|
|
42
|
+
if (!config) return;
|
|
43
|
+
|
|
44
|
+
const { googleProvider, keys } = config;
|
|
45
|
+
const type: IAuthGoogleProviderType = "spa";
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const data = new URLSearchParams({
|
|
49
|
+
client_id: googleProvider?.clientId!,
|
|
50
|
+
grant_type: "authorization_code",
|
|
51
|
+
code: code,
|
|
52
|
+
redirect_uri: config?.spaCallbackUri!,
|
|
53
|
+
code_verifier: verifier,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const res = await http.post(
|
|
57
|
+
"https://oauth2.googleapis.com/token",
|
|
58
|
+
data.toString(),
|
|
59
|
+
{
|
|
60
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const tokens = res.data as any;
|
|
65
|
+
|
|
66
|
+
if (tokens?.access_token) {
|
|
67
|
+
removeSessionItem(keys.PROVIDER);
|
|
68
|
+
removeSessionItem(keys.VERIFIER);
|
|
69
|
+
removeSessionItem(keys.PROVIDER_TYPE);
|
|
70
|
+
|
|
71
|
+
keepToStorage(config.storage!, keys.PROVIDER, AUTH_PROVIDERS.google);
|
|
72
|
+
keepToStorage(config.storage!, keys.PROVIDER_TYPE, type);
|
|
73
|
+
keepToStorage(config.storage!, keys.ACCESS_TOKEN, tokens.access_token);
|
|
74
|
+
keepToStorage(
|
|
75
|
+
config.storage!,
|
|
76
|
+
keys.EXPIRES_IN,
|
|
77
|
+
String(Date.now() + tokens.expires_in * 1000)
|
|
78
|
+
);
|
|
79
|
+
keepToStorage(config.storage!, keys.REFRESH_TOKEN, tokens.refresh_token);
|
|
80
|
+
keepToStorage(config.storage!, keys.TOKEN_TYPE, tokens.token_type);
|
|
81
|
+
keepToStorage(config.storage!, keys.ID_TOKEN, tokens.id_token);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return Promise.reject(error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const getGoogleSessionUser = async () => {
|
|
89
|
+
const config = getConfig();
|
|
90
|
+
if (!config) return;
|
|
91
|
+
|
|
92
|
+
const { keys } = config;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const accessToken = getFromStorage(config.storage!, keys.ACCESS_TOKEN);
|
|
96
|
+
if (!accessToken) return;
|
|
97
|
+
|
|
98
|
+
const res = await http.get(
|
|
99
|
+
`https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=${accessToken}`
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const user = res.data;
|
|
103
|
+
|
|
104
|
+
return user;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return Promise.reject(error);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const refreshGoogleAccessToken = async () => {
|
|
111
|
+
const config = getConfig();
|
|
112
|
+
if (!config) return;
|
|
113
|
+
|
|
114
|
+
const { googleProvider, keys } = config;
|
|
115
|
+
|
|
116
|
+
const refreshToken = getFromStorage(config.storage!, keys.REFRESH_TOKEN);
|
|
117
|
+
|
|
118
|
+
if (!refreshToken) return;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const data = new URLSearchParams({
|
|
122
|
+
client_id: googleProvider?.clientId!,
|
|
123
|
+
grant_type: "refresh_token",
|
|
124
|
+
refresh_token: refreshToken,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const res = await http.post(
|
|
128
|
+
"https://oauth2.googleapis.com/token",
|
|
129
|
+
data.toString(),
|
|
130
|
+
{
|
|
131
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const tokens = res.data as any;
|
|
136
|
+
|
|
137
|
+
if (!tokens.access_token) {
|
|
138
|
+
console.error("Failed to refresh access token", tokens);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
keepToStorage(config.storage!, keys.ACCESS_TOKEN, tokens.access_token);
|
|
143
|
+
if (tokens.expires_in) {
|
|
144
|
+
keepToStorage(
|
|
145
|
+
config.storage!,
|
|
146
|
+
keys.EXPIRES_IN,
|
|
147
|
+
String(Date.now() + tokens.expires_in * 1000)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (tokens.id_token)
|
|
151
|
+
keepToStorage(config.storage!, keys.ID_TOKEN, tokens.id_token);
|
|
152
|
+
if (tokens.token_type)
|
|
153
|
+
keepToStorage(config.storage!, keys.TOKEN_TYPE, tokens.token_type);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return Promise.reject(error);
|
|
156
|
+
}
|
|
157
|
+
};
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface IAuthContext {
|
|
4
|
+
loginInProgress: boolean;
|
|
5
|
+
signinWithGoogle: () => Promise<void>;
|
|
6
|
+
logOut: ILogoutFunc;
|
|
7
|
+
user?: any;
|
|
8
|
+
error?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IAuthProvider {
|
|
12
|
+
authConfig: IAuthConfig;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type IAuthStorageType = "session" | "local";
|
|
17
|
+
|
|
18
|
+
export type IAuthGoogleProviderType = "spa";
|
|
19
|
+
|
|
20
|
+
export interface IAuthGoogleProvider {
|
|
21
|
+
clientId: string;
|
|
22
|
+
type?: IAuthGoogleProviderType;
|
|
23
|
+
scope?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Input from users of the package, some optional values
|
|
27
|
+
export type IAuthConfig = {
|
|
28
|
+
googleProvider?: IAuthGoogleProvider;
|
|
29
|
+
spaCallbackUri?: string;
|
|
30
|
+
logoutRedirect?: string;
|
|
31
|
+
preLogin?: () => void;
|
|
32
|
+
postLogin?: () => void;
|
|
33
|
+
onLogInError?: (err: any) => void;
|
|
34
|
+
storageKeyPrefix?: string;
|
|
35
|
+
storage?: IAuthStorageType;
|
|
36
|
+
|
|
37
|
+
error?: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Keys = {
|
|
41
|
+
PROVIDER_TYPE: string;
|
|
42
|
+
PROVIDER: string;
|
|
43
|
+
VERIFIER: string;
|
|
44
|
+
ACCESS_TOKEN: string;
|
|
45
|
+
EXPIRES_IN: string;
|
|
46
|
+
REFRESH_TOKEN: string;
|
|
47
|
+
TOKEN_TYPE: string;
|
|
48
|
+
ID_TOKEN: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type IAuthInternalConfig = IAuthConfig & { keys: Keys };
|
|
52
|
+
|
|
53
|
+
export type ILogoutFunc = (args?: {
|
|
54
|
+
logoutHint?: string;
|
|
55
|
+
redirect?: boolean;
|
|
56
|
+
redirectUri?: string;
|
|
57
|
+
}) => void;
|
package/src/useAuth.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useContext } from 'react'
|
|
2
|
+
import { AuthContext } from './AuthContext'
|
|
3
|
+
import type { IAuthContext } from './types'
|
|
4
|
+
|
|
5
|
+
export function useAuth(): IAuthContext {
|
|
6
|
+
const ctx = useContext(AuthContext)
|
|
7
|
+
|
|
8
|
+
if (!ctx) {
|
|
9
|
+
throw new Error('useAuth must be used within an AuthProvider')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return ctx
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const AUTH_PROVIDERS = { google: 'google' } as const
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
type RequestOptions = {
|
|
2
|
+
headers?: Record<string, string>;
|
|
3
|
+
body?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
async function request(
|
|
7
|
+
url: string,
|
|
8
|
+
options: RequestOptions & { method: string }
|
|
9
|
+
) {
|
|
10
|
+
let body: any;
|
|
11
|
+
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
body = options.body;
|
|
15
|
+
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
16
|
+
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
method: options.method,
|
|
19
|
+
headers,
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
const error = await response.json().catch(() => ({}));
|
|
25
|
+
const expectedError = response.status >= 400 && response.status < 500;
|
|
26
|
+
|
|
27
|
+
if (!expectedError) {
|
|
28
|
+
console.log({ error });
|
|
29
|
+
console.log("Unexpected error occurs");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Promise.reject({ status: response.status, ...error });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { data: await response.json() };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.log("Network or unexpected error:", error);
|
|
38
|
+
return Promise.reject(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
get: (url: string, options?: RequestOptions) =>
|
|
44
|
+
request(url, { ...options, method: "GET" }),
|
|
45
|
+
post: (url: string, body?: string, options?: RequestOptions) =>
|
|
46
|
+
request(url, { ...options, method: "POST", body }),
|
|
47
|
+
put: (url: string, body?: string, options?: RequestOptions) =>
|
|
48
|
+
request(url, { ...options, method: "PUT", body }),
|
|
49
|
+
delete: (url: string, options?: RequestOptions) =>
|
|
50
|
+
request(url, { ...options, method: "DELETE" }),
|
|
51
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const toURL = (input: string): URL => {
|
|
2
|
+
// If input is absolute, URL works directly
|
|
3
|
+
try {
|
|
4
|
+
return new URL(input)
|
|
5
|
+
} catch {
|
|
6
|
+
// If input is relative, use window.location.origin as base
|
|
7
|
+
return new URL(input, window.location.origin)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const makeKey = (key: string, prefix?: string) => {
|
|
12
|
+
return prefix ? `${prefix}_${key}` : key
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import getPkce from "oauth-pkce";
|
|
2
|
+
|
|
3
|
+
export const generatePkce = async (): Promise<{
|
|
4
|
+
verifier: string;
|
|
5
|
+
challenge: string;
|
|
6
|
+
algorithm: string;
|
|
7
|
+
}> => {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
getPkce(50, (error, { verifier, challenge }) => {
|
|
10
|
+
if (error) reject(error);
|
|
11
|
+
|
|
12
|
+
resolve({ verifier, challenge, algorithm: "S256" });
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function setSessionItem(key: string, value: string, ttlMs: number) {
|
|
2
|
+
const data = {
|
|
3
|
+
value,
|
|
4
|
+
expires: Date.now() + ttlMs,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
sessionStorage.setItem(key, JSON.stringify(data))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getSessionItem(key: string): string | null {
|
|
11
|
+
const dataStr = sessionStorage.getItem(key)
|
|
12
|
+
if (!dataStr) return null
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(dataStr)
|
|
16
|
+
if (Date.now() > data.expires) {
|
|
17
|
+
sessionStorage.removeItem(key)
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return data.value
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeSessionItem(key: string) {
|
|
28
|
+
try {
|
|
29
|
+
sessionStorage.removeItem(key)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IAuthStorageType } from '../types'
|
|
2
|
+
|
|
3
|
+
export const keepToStorage = (type: IAuthStorageType, key: string, value: string) => {
|
|
4
|
+
if (type === 'local') localStorage.setItem(key, value)
|
|
5
|
+
else if (type === 'session') sessionStorage.setItem(key, value)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const removeFromStorage = (type: IAuthStorageType, key: string) => {
|
|
9
|
+
if (type === 'local') localStorage.removeItem(key)
|
|
10
|
+
else if (type === 'session') sessionStorage.removeItem(key)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const getFromStorage = (type: IAuthStorageType, key: string) => {
|
|
14
|
+
if (type === 'local') return localStorage.getItem(key) ?? ''
|
|
15
|
+
else if (type === 'session') return sessionStorage.getItem(key) ?? ''
|
|
16
|
+
|
|
17
|
+
return ''
|
|
18
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES6",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationDir": "dist/types",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"jsx": "react",
|
|
10
|
+
"moduleResolution": "node",
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|