@idealyst/oauth-client 0.0.1
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 +310 -0
- package/package.json +60 -0
- package/src/examples/google-example.ts +108 -0
- package/src/index.ts +55 -0
- package/src/native-client.ts +159 -0
- package/src/storage.ts +60 -0
- package/src/types.ts +47 -0
- package/src/web-client.ts +272 -0
package/README.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# @idealyst/oauth-client
|
|
2
|
+
|
|
3
|
+
Universal OAuth2 client for web and React Native applications with a single API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🌐 **Universal**: Works on both web and React Native with the same API
|
|
8
|
+
- 🔐 **Secure**: Uses PKCE for mobile, supports client secrets for web
|
|
9
|
+
- 🏪 **Storage**: Automatic token storage with customizable adapters
|
|
10
|
+
- 🔄 **Refresh**: Automatic token refresh handling
|
|
11
|
+
- 🚪 **Logout**: Proper logout with token revocation
|
|
12
|
+
- 📱 **Mobile**: Uses `react-native-app-auth` for secure system browser flow
|
|
13
|
+
- 🎯 **TypeScript**: Fully typed for better developer experience
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @idealyst/oauth-client
|
|
19
|
+
# or
|
|
20
|
+
yarn add @idealyst/oauth-client
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Additional Dependencies
|
|
24
|
+
|
|
25
|
+
#### For React Native:
|
|
26
|
+
```bash
|
|
27
|
+
npm install react-native-app-auth @react-native-async-storage/async-storage
|
|
28
|
+
# Follow react-native-app-auth setup instructions for iOS/Android
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
#### For Web:
|
|
32
|
+
No additional dependencies required.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { createOAuthClient, providers } from '@idealyst/oauth-client'
|
|
38
|
+
|
|
39
|
+
// Create OAuth client (works on both web and mobile)
|
|
40
|
+
const client = createOAuthClient({
|
|
41
|
+
...providers.google,
|
|
42
|
+
clientId: 'your-google-client-id',
|
|
43
|
+
redirectUrl: 'com.yourapp://oauth/callback', // Mobile
|
|
44
|
+
// redirectUrl: 'http://localhost:3000/auth/callback', // Web
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Authorize user - same API on web and mobile!
|
|
48
|
+
try {
|
|
49
|
+
const result = await client.authorize()
|
|
50
|
+
console.log('Access token:', result.tokens.accessToken)
|
|
51
|
+
console.log('User data:', result.user)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Authorization failed:', error)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### createOAuthClient(config, storage?)
|
|
60
|
+
|
|
61
|
+
Creates a platform-specific OAuth client with a unified API.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
const client = createOAuthClient({
|
|
65
|
+
issuer: 'https://accounts.google.com',
|
|
66
|
+
clientId: 'your-client-id',
|
|
67
|
+
redirectUrl: 'your-app://oauth',
|
|
68
|
+
scopes: ['openid', 'profile', 'email'],
|
|
69
|
+
|
|
70
|
+
// Optional
|
|
71
|
+
additionalParameters: { prompt: 'consent' },
|
|
72
|
+
customHeaders: { 'X-Custom': 'value' },
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**⚠️ Security**: Client secrets are **never** used in this library. All flows use PKCE for security, which is the OAuth 2.1 standard for public clients.
|
|
77
|
+
|
|
78
|
+
### OAuthClient Methods
|
|
79
|
+
|
|
80
|
+
#### authorize()
|
|
81
|
+
Initiates the OAuth flow and returns tokens.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const result = await client.authorize()
|
|
85
|
+
// result.tokens: { accessToken, refreshToken, idToken, expiresAt, ... }
|
|
86
|
+
// result.user: Additional user data (provider-specific)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### refresh(refreshToken)
|
|
90
|
+
Refreshes an expired access token.
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const result = await client.refresh(refreshToken)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### getStoredTokens()
|
|
97
|
+
Retrieves stored tokens from storage.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
const tokens = await client.getStoredTokens()
|
|
101
|
+
if (tokens?.expiresAt && tokens.expiresAt < new Date()) {
|
|
102
|
+
// Token is expired, refresh it
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### revoke(token)
|
|
107
|
+
Revokes a specific token.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
await client.revoke(accessToken)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### logout()
|
|
114
|
+
Logs out the user, revokes tokens, and clears storage.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
await client.logout()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### clearStoredTokens()
|
|
121
|
+
Manually clears stored tokens.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
await client.clearStoredTokens()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Provider Configurations
|
|
128
|
+
|
|
129
|
+
Pre-configured settings for popular OAuth providers:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { providers } from '@idealyst/oauth-client'
|
|
133
|
+
|
|
134
|
+
// Google
|
|
135
|
+
const googleClient = createOAuthClient({
|
|
136
|
+
...providers.google,
|
|
137
|
+
clientId: 'your-google-client-id',
|
|
138
|
+
redirectUrl: 'your-redirect-url',
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// GitHub
|
|
142
|
+
const githubClient = createOAuthClient({
|
|
143
|
+
...providers.github,
|
|
144
|
+
clientId: 'your-github-client-id',
|
|
145
|
+
redirectUrl: 'your-redirect-url',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Microsoft
|
|
149
|
+
const msClient = createOAuthClient({
|
|
150
|
+
...providers.microsoft,
|
|
151
|
+
clientId: 'your-microsoft-client-id',
|
|
152
|
+
redirectUrl: 'your-redirect-url',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Auth0
|
|
156
|
+
const auth0Client = createOAuthClient({
|
|
157
|
+
...providers.auth0('your-domain.auth0.com'),
|
|
158
|
+
clientId: 'your-auth0-client-id',
|
|
159
|
+
redirectUrl: 'your-redirect-url',
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Okta
|
|
163
|
+
const oktaClient = createOAuthClient({
|
|
164
|
+
...providers.okta('dev-123.okta.com'),
|
|
165
|
+
clientId: 'your-okta-client-id',
|
|
166
|
+
redirectUrl: 'your-redirect-url',
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Custom Storage
|
|
171
|
+
|
|
172
|
+
By default, the library uses `localStorage` on web and `AsyncStorage` on mobile. You can provide a custom storage adapter:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
import { createOAuthClient } from '@idealyst/oauth-client'
|
|
176
|
+
|
|
177
|
+
const customStorage = {
|
|
178
|
+
async getItem(key: string): Promise<string | null> {
|
|
179
|
+
// Your storage implementation
|
|
180
|
+
},
|
|
181
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
182
|
+
// Your storage implementation
|
|
183
|
+
},
|
|
184
|
+
async removeItem(key: string): Promise<void> {
|
|
185
|
+
// Your storage implementation
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const client = createOAuthClient(config, customStorage)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Platform-Specific Configuration
|
|
193
|
+
|
|
194
|
+
### Web Configuration
|
|
195
|
+
|
|
196
|
+
For web applications, use standard HTTP redirect URLs:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
const webClient = createOAuthClient({
|
|
200
|
+
...providers.google,
|
|
201
|
+
clientId: 'your-google-client-id',
|
|
202
|
+
redirectUrl: 'https://yourapp.com/auth/callback',
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**⚠️ Security Note**: Client secrets should **NEVER** be included in client-side code. This library uses PKCE (Proof Key for Code Exchange) which provides security for public clients without requiring client secrets.
|
|
207
|
+
|
|
208
|
+
### Mobile Configuration
|
|
209
|
+
|
|
210
|
+
For mobile apps, you need to:
|
|
211
|
+
|
|
212
|
+
1. **Configure custom URL scheme** in your app
|
|
213
|
+
2. **Register the URL scheme** with your OAuth provider
|
|
214
|
+
3. **Use the custom URL** as redirect URL
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
const mobileClient = createOAuthClient({
|
|
218
|
+
...providers.google,
|
|
219
|
+
clientId: 'your-google-client-id',
|
|
220
|
+
redirectUrl: 'com.yourapp://oauth/callback',
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## React Native Setup
|
|
225
|
+
|
|
226
|
+
### iOS Configuration
|
|
227
|
+
|
|
228
|
+
1. Add URL scheme to your `Info.plist`:
|
|
229
|
+
|
|
230
|
+
```xml
|
|
231
|
+
<key>CFBundleURLTypes</key>
|
|
232
|
+
<array>
|
|
233
|
+
<dict>
|
|
234
|
+
<key>CFBundleURLName</key>
|
|
235
|
+
<string>com.yourapp.oauth</string>
|
|
236
|
+
<key>CFBundleURLSchemes</key>
|
|
237
|
+
<array>
|
|
238
|
+
<string>com.yourapp</string>
|
|
239
|
+
</array>
|
|
240
|
+
</dict>
|
|
241
|
+
</array>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Android Configuration
|
|
245
|
+
|
|
246
|
+
1. Add intent filter to `android/app/src/main/AndroidManifest.xml`:
|
|
247
|
+
|
|
248
|
+
```xml
|
|
249
|
+
<activity android:name=".MainActivity">
|
|
250
|
+
<intent-filter android:label="filter_react_native">
|
|
251
|
+
<action android:name="android.intent.action.VIEW" />
|
|
252
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
253
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
254
|
+
<data android:scheme="com.yourapp" />
|
|
255
|
+
</intent-filter>
|
|
256
|
+
</activity>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
2. Follow the [react-native-app-auth setup guide](https://github.com/FormidableLabs/react-native-app-auth#setup) for additional configuration.
|
|
260
|
+
|
|
261
|
+
### OAuth Provider Setup
|
|
262
|
+
|
|
263
|
+
Register your custom URL scheme as a valid redirect URI in your OAuth provider:
|
|
264
|
+
|
|
265
|
+
- **Google**: Add `com.yourapp://oauth/callback` to "Authorized redirect URIs"
|
|
266
|
+
- **GitHub**: Set "Authorization callback URL" to `com.yourapp://oauth/callback`
|
|
267
|
+
- **Other providers**: Add the URL scheme to allowed callback URLs
|
|
268
|
+
|
|
269
|
+
## How Mobile OAuth Works
|
|
270
|
+
|
|
271
|
+
1. **App opens system browser** (Safari/Chrome) with OAuth URL
|
|
272
|
+
2. **User authenticates** in the browser (can use saved passwords, Touch ID, etc.)
|
|
273
|
+
3. **Provider redirects** to your custom URL scheme (`com.yourapp://oauth/callback`)
|
|
274
|
+
4. **OS recognizes the scheme** and opens your app
|
|
275
|
+
5. **react-native-app-auth** automatically extracts tokens and returns them
|
|
276
|
+
|
|
277
|
+
This provides the most secure OAuth flow for mobile apps, as recommended by OAuth 2.0 security best practices.
|
|
278
|
+
|
|
279
|
+
## Error Handling
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
try {
|
|
283
|
+
const result = await client.authorize()
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error.message.includes('User cancelled')) {
|
|
286
|
+
// User cancelled the authorization
|
|
287
|
+
} else if (error.message.includes('network')) {
|
|
288
|
+
// Network error
|
|
289
|
+
} else {
|
|
290
|
+
// Other OAuth error
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## TypeScript
|
|
296
|
+
|
|
297
|
+
The library is fully typed. Import types as needed:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
import type {
|
|
301
|
+
OAuthConfig,
|
|
302
|
+
OAuthTokens,
|
|
303
|
+
OAuthResult,
|
|
304
|
+
OAuthClient
|
|
305
|
+
} from '@idealyst/oauth-client'
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@idealyst/oauth-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Universal OAuth2 client for web and React Native",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch",
|
|
18
|
+
"clean": "rm -rf dist",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepublishOnly": "yarn build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"oauth",
|
|
24
|
+
"oauth2",
|
|
25
|
+
"authentication",
|
|
26
|
+
"react",
|
|
27
|
+
"react-native",
|
|
28
|
+
"cross-platform"
|
|
29
|
+
],
|
|
30
|
+
"author": "",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"react-native-app-auth": "^7.2.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "^18.3.18",
|
|
37
|
+
"@types/react-native": "^0.73.0",
|
|
38
|
+
"react": "^19.1.0",
|
|
39
|
+
"react-native": "^0.80.1",
|
|
40
|
+
"tsup": "^8.3.5",
|
|
41
|
+
"typescript": "^5.7.3"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18",
|
|
45
|
+
"react-native": ">=0.72"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react-native": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"dist",
|
|
54
|
+
"src",
|
|
55
|
+
"README.md"
|
|
56
|
+
],
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createOAuthClient, providers } from '../index'
|
|
2
|
+
|
|
3
|
+
// Example: Google OAuth - works on both web and mobile
|
|
4
|
+
export async function setupGoogleOAuth() {
|
|
5
|
+
const client = createOAuthClient({
|
|
6
|
+
...providers.google,
|
|
7
|
+
clientId: 'your-google-client-id',
|
|
8
|
+
redirectUrl: 'com.yourapp://oauth/callback', // Mobile
|
|
9
|
+
// redirectUrl: 'https://yourapp.com/auth/callback', // Web
|
|
10
|
+
scopes: ['openid', 'profile', 'email'],
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
// Authorize user - same API on both platforms!
|
|
15
|
+
const result = await client.authorize()
|
|
16
|
+
console.log('Access token:', result.tokens.accessToken)
|
|
17
|
+
console.log('User info:', result.user)
|
|
18
|
+
|
|
19
|
+
// Token management works the same on both platforms
|
|
20
|
+
const storedTokens = await client.getStoredTokens()
|
|
21
|
+
if (storedTokens) {
|
|
22
|
+
console.log('Stored tokens:', storedTokens)
|
|
23
|
+
|
|
24
|
+
// Check if token needs refresh
|
|
25
|
+
if (storedTokens.expiresAt && storedTokens.expiresAt < new Date()) {
|
|
26
|
+
if (storedTokens.refreshToken) {
|
|
27
|
+
const refreshed = await client.refresh(storedTokens.refreshToken)
|
|
28
|
+
console.log('Refreshed tokens:', refreshed.tokens)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Logout when done
|
|
34
|
+
await client.logout()
|
|
35
|
+
console.log('Logged out successfully')
|
|
36
|
+
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('OAuth error:', error)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Example: Multiple providers
|
|
43
|
+
export async function setupMultipleProviders() {
|
|
44
|
+
const providers = [
|
|
45
|
+
{ name: 'Google', config: { ...providers.google, clientId: 'google-client-id' } },
|
|
46
|
+
{ name: 'GitHub', config: { ...providers.github, clientId: 'github-client-id' } },
|
|
47
|
+
{ name: 'Microsoft', config: { ...providers.microsoft, clientId: 'ms-client-id' } },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
for (const provider of providers) {
|
|
51
|
+
try {
|
|
52
|
+
const client = createOAuthClient({
|
|
53
|
+
...provider.config,
|
|
54
|
+
redirectUrl: 'com.yourapp://oauth/callback',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const result = await client.authorize()
|
|
58
|
+
console.log(`${provider.name} login successful:`, result.tokens.accessToken)
|
|
59
|
+
return { provider: provider.name, tokens: result.tokens }
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(`${provider.name} login failed:`, error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error('All OAuth providers failed')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Example: Custom provider configuration
|
|
69
|
+
export async function setupCustomProvider() {
|
|
70
|
+
const client = createOAuthClient({
|
|
71
|
+
issuer: 'https://your-oauth-server.com',
|
|
72
|
+
clientId: 'your-client-id',
|
|
73
|
+
redirectUrl: 'com.yourapp://oauth/callback',
|
|
74
|
+
scopes: ['read', 'write'],
|
|
75
|
+
additionalParameters: {
|
|
76
|
+
prompt: 'consent',
|
|
77
|
+
},
|
|
78
|
+
customHeaders: {
|
|
79
|
+
'X-Custom-Header': 'value',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const result = await client.authorize()
|
|
85
|
+
return result.tokens
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('Custom OAuth error:', error)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Example: Platform-specific configuration
|
|
93
|
+
export async function setupPlatformSpecificOAuth() {
|
|
94
|
+
// Check if we're on mobile or web and configure accordingly
|
|
95
|
+
const isMobile = typeof navigator !== 'undefined' && navigator.product === 'ReactNative'
|
|
96
|
+
|
|
97
|
+
const client = createOAuthClient({
|
|
98
|
+
...providers.google,
|
|
99
|
+
clientId: 'your-google-client-id',
|
|
100
|
+
redirectUrl: isMobile
|
|
101
|
+
? 'com.yourapp://oauth/callback' // Mobile deep link
|
|
102
|
+
: 'https://yourapp.com/auth/callback', // Web callback
|
|
103
|
+
scopes: ['openid', 'profile', 'email'],
|
|
104
|
+
// Note: No client secret needed - PKCE provides security for public clients
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return await client.authorize()
|
|
108
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export * from './types'
|
|
2
|
+
export * from './storage'
|
|
3
|
+
|
|
4
|
+
import type { OAuthConfig, OAuthClient, StorageAdapter } from './types'
|
|
5
|
+
import { WebOAuthClient } from './web-client'
|
|
6
|
+
import { NativeOAuthClient } from './native-client'
|
|
7
|
+
import { createDefaultStorage } from './storage'
|
|
8
|
+
|
|
9
|
+
export function createOAuthClient(
|
|
10
|
+
config: OAuthConfig,
|
|
11
|
+
storage?: StorageAdapter
|
|
12
|
+
): OAuthClient {
|
|
13
|
+
const storageAdapter = storage || createDefaultStorage()
|
|
14
|
+
|
|
15
|
+
// Check if we're in React Native environment
|
|
16
|
+
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
|
17
|
+
return new NativeOAuthClient(config, storageAdapter)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Default to web client
|
|
21
|
+
return new WebOAuthClient(config, storageAdapter)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Common provider configurations
|
|
25
|
+
export const providers = {
|
|
26
|
+
google: {
|
|
27
|
+
issuer: 'https://accounts.google.com',
|
|
28
|
+
scopes: ['openid', 'profile', 'email'],
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
github: {
|
|
32
|
+
issuer: 'https://github.com',
|
|
33
|
+
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
|
34
|
+
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
|
35
|
+
scopes: ['user'],
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
microsoft: {
|
|
39
|
+
issuer: 'https://login.microsoftonline.com/common/v2.0',
|
|
40
|
+
scopes: ['openid', 'profile', 'email'],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
auth0: (domain: string) => ({
|
|
44
|
+
issuer: `https://${domain}`,
|
|
45
|
+
scopes: ['openid', 'profile', 'email'],
|
|
46
|
+
}),
|
|
47
|
+
|
|
48
|
+
okta: (domain: string) => ({
|
|
49
|
+
issuer: `https://${domain}`,
|
|
50
|
+
scopes: ['openid', 'profile', 'email'],
|
|
51
|
+
}),
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
export { WebOAuthClient } from './web-client'
|
|
55
|
+
export { NativeOAuthClient } from './native-client'
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { authorize, refresh, revoke, type AuthConfiguration, type AuthorizeResult } from 'react-native-app-auth'
|
|
2
|
+
import type { OAuthClient, OAuthConfig, OAuthResult, OAuthTokens, StorageAdapter } from './types'
|
|
3
|
+
|
|
4
|
+
export class NativeOAuthClient implements OAuthClient {
|
|
5
|
+
private config: OAuthConfig
|
|
6
|
+
private storage: StorageAdapter
|
|
7
|
+
private storageKey: string
|
|
8
|
+
private authConfig: AuthConfiguration
|
|
9
|
+
|
|
10
|
+
constructor(config: OAuthConfig, storage: StorageAdapter) {
|
|
11
|
+
this.config = config
|
|
12
|
+
this.storage = storage
|
|
13
|
+
this.storageKey = `oauth_tokens_${config.clientId}`
|
|
14
|
+
|
|
15
|
+
this.authConfig = {
|
|
16
|
+
issuer: config.issuer,
|
|
17
|
+
clientId: config.clientId,
|
|
18
|
+
redirectUrl: config.redirectUrl,
|
|
19
|
+
scopes: config.scopes || [],
|
|
20
|
+
additionalParameters: config.additionalParameters || {},
|
|
21
|
+
customHeaders: config.customHeaders || {},
|
|
22
|
+
usesPkce: true,
|
|
23
|
+
usesStateParam: true,
|
|
24
|
+
|
|
25
|
+
// Optional endpoint overrides
|
|
26
|
+
...(config.authorizationEndpoint && {
|
|
27
|
+
authorizationEndpoint: config.authorizationEndpoint
|
|
28
|
+
}),
|
|
29
|
+
...(config.tokenEndpoint && {
|
|
30
|
+
tokenEndpoint: config.tokenEndpoint
|
|
31
|
+
}),
|
|
32
|
+
...(config.revocationEndpoint && {
|
|
33
|
+
revocationEndpoint: config.revocationEndpoint
|
|
34
|
+
}),
|
|
35
|
+
...(config.endSessionEndpoint && {
|
|
36
|
+
endSessionEndpoint: config.endSessionEndpoint
|
|
37
|
+
}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async authorize(): Promise<OAuthResult> {
|
|
42
|
+
try {
|
|
43
|
+
const result: AuthorizeResult = await authorize(this.authConfig)
|
|
44
|
+
|
|
45
|
+
const tokens: OAuthTokens = {
|
|
46
|
+
accessToken: result.accessToken,
|
|
47
|
+
refreshToken: result.refreshToken,
|
|
48
|
+
idToken: result.idToken,
|
|
49
|
+
tokenType: result.tokenType || 'Bearer',
|
|
50
|
+
expiresAt: result.accessTokenExpirationDate
|
|
51
|
+
? new Date(result.accessTokenExpirationDate)
|
|
52
|
+
: undefined,
|
|
53
|
+
scopes: result.scopes,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await this.storeTokens(tokens)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
tokens,
|
|
60
|
+
user: result.additionalParameters
|
|
61
|
+
}
|
|
62
|
+
} catch (error: any) {
|
|
63
|
+
throw new Error(`Authorization failed: ${error.message || error}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async refresh(refreshToken: string): Promise<OAuthResult> {
|
|
68
|
+
try {
|
|
69
|
+
const result = await refresh(this.authConfig, {
|
|
70
|
+
refreshToken,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const tokens: OAuthTokens = {
|
|
74
|
+
accessToken: result.accessToken,
|
|
75
|
+
refreshToken: result.refreshToken || refreshToken, // Keep original if not returned
|
|
76
|
+
idToken: result.idToken,
|
|
77
|
+
tokenType: result.tokenType || 'Bearer',
|
|
78
|
+
expiresAt: result.accessTokenExpirationDate
|
|
79
|
+
? new Date(result.accessTokenExpirationDate)
|
|
80
|
+
: undefined,
|
|
81
|
+
scopes: result.scopes,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await this.storeTokens(tokens)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
tokens,
|
|
88
|
+
user: result.additionalParameters
|
|
89
|
+
}
|
|
90
|
+
} catch (error: any) {
|
|
91
|
+
throw new Error(`Token refresh failed: ${error.message || error}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async revoke(token: string): Promise<void> {
|
|
96
|
+
try {
|
|
97
|
+
await revoke(this.authConfig, {
|
|
98
|
+
tokenToRevoke: token,
|
|
99
|
+
sendClientId: true,
|
|
100
|
+
})
|
|
101
|
+
} catch (error: any) {
|
|
102
|
+
// Some providers return errors for already revoked tokens
|
|
103
|
+
// Don't throw unless it's a network error
|
|
104
|
+
if (error.message?.includes('network') || error.message?.includes('connection')) {
|
|
105
|
+
throw new Error(`Token revocation failed: ${error.message || error}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async logout(): Promise<void> {
|
|
111
|
+
const tokens = await this.getStoredTokens()
|
|
112
|
+
|
|
113
|
+
// Revoke tokens if available
|
|
114
|
+
if (tokens?.accessToken) {
|
|
115
|
+
try {
|
|
116
|
+
await this.revoke(tokens.accessToken)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn('Failed to revoke access token:', error)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (tokens?.refreshToken) {
|
|
123
|
+
try {
|
|
124
|
+
await this.revoke(tokens.refreshToken)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.warn('Failed to revoke refresh token:', error)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await this.clearStoredTokens()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getStoredTokens(): Promise<OAuthTokens | null> {
|
|
134
|
+
const stored = await this.storage.getItem(this.storageKey)
|
|
135
|
+
if (!stored) return null
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const tokens = JSON.parse(stored)
|
|
139
|
+
return {
|
|
140
|
+
...tokens,
|
|
141
|
+
expiresAt: tokens.expiresAt ? new Date(tokens.expiresAt) : undefined,
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async clearStoredTokens(): Promise<void> {
|
|
149
|
+
await this.storage.removeItem(this.storageKey)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async storeTokens(tokens: OAuthTokens): Promise<void> {
|
|
153
|
+
const serializable = {
|
|
154
|
+
...tokens,
|
|
155
|
+
expiresAt: tokens.expiresAt?.toISOString(),
|
|
156
|
+
}
|
|
157
|
+
await this.storage.setItem(this.storageKey, JSON.stringify(serializable))
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { StorageAdapter } from './types'
|
|
2
|
+
|
|
3
|
+
export class WebStorage implements StorageAdapter {
|
|
4
|
+
async getItem(key: string): Promise<string | null> {
|
|
5
|
+
if (typeof localStorage === 'undefined') {
|
|
6
|
+
return null
|
|
7
|
+
}
|
|
8
|
+
return localStorage.getItem(key)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
12
|
+
if (typeof localStorage === 'undefined') {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
localStorage.setItem(key, value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async removeItem(key: string): Promise<void> {
|
|
19
|
+
if (typeof localStorage === 'undefined') {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
localStorage.removeItem(key)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ReactNativeStorage implements StorageAdapter {
|
|
27
|
+
private AsyncStorage: any
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
try {
|
|
31
|
+
this.AsyncStorage = require('@react-native-async-storage/async-storage').default
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'AsyncStorage is required for React Native. Please install @react-native-async-storage/async-storage'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getItem(key: string): Promise<string | null> {
|
|
40
|
+
return await this.AsyncStorage.getItem(key)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async setItem(key: string, value: string): Promise<void> {
|
|
44
|
+
await this.AsyncStorage.setItem(key, value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async removeItem(key: string): Promise<void> {
|
|
48
|
+
await this.AsyncStorage.removeItem(key)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createDefaultStorage(): StorageAdapter {
|
|
53
|
+
// Check if we're in React Native environment
|
|
54
|
+
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
|
55
|
+
return new ReactNativeStorage()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Default to web storage
|
|
59
|
+
return new WebStorage()
|
|
60
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface OAuthConfig {
|
|
2
|
+
clientId: string
|
|
3
|
+
redirectUrl: string
|
|
4
|
+
additionalParameters?: Record<string, string>
|
|
5
|
+
customHeaders?: Record<string, string>
|
|
6
|
+
scopes?: string[]
|
|
7
|
+
|
|
8
|
+
// Provider endpoints
|
|
9
|
+
issuer: string
|
|
10
|
+
authorizationEndpoint?: string
|
|
11
|
+
tokenEndpoint?: string
|
|
12
|
+
revocationEndpoint?: string
|
|
13
|
+
endSessionEndpoint?: string
|
|
14
|
+
|
|
15
|
+
// Mobile-specific
|
|
16
|
+
androidPackageName?: string
|
|
17
|
+
iosUrlScheme?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OAuthTokens {
|
|
21
|
+
accessToken: string
|
|
22
|
+
refreshToken?: string
|
|
23
|
+
idToken?: string
|
|
24
|
+
tokenType?: string
|
|
25
|
+
expiresAt?: Date
|
|
26
|
+
scopes?: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OAuthResult {
|
|
30
|
+
tokens: OAuthTokens
|
|
31
|
+
user?: any
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OAuthClient {
|
|
35
|
+
authorize(): Promise<OAuthResult>
|
|
36
|
+
refresh(refreshToken: string): Promise<OAuthResult>
|
|
37
|
+
revoke(token: string): Promise<void>
|
|
38
|
+
logout(): Promise<void>
|
|
39
|
+
getStoredTokens(): Promise<OAuthTokens | null>
|
|
40
|
+
clearStoredTokens(): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StorageAdapter {
|
|
44
|
+
getItem(key: string): Promise<string | null>
|
|
45
|
+
setItem(key: string, value: string): Promise<void>
|
|
46
|
+
removeItem(key: string): Promise<void>
|
|
47
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { OAuthClient, OAuthConfig, OAuthResult, OAuthTokens, StorageAdapter } from './types'
|
|
2
|
+
|
|
3
|
+
export class WebOAuthClient implements OAuthClient {
|
|
4
|
+
private config: OAuthConfig
|
|
5
|
+
private storage: StorageAdapter
|
|
6
|
+
private storageKey: string
|
|
7
|
+
|
|
8
|
+
constructor(config: OAuthConfig, storage: StorageAdapter) {
|
|
9
|
+
this.config = config
|
|
10
|
+
this.storage = storage
|
|
11
|
+
this.storageKey = `oauth_tokens_${config.clientId}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async authorize(): Promise<OAuthResult> {
|
|
15
|
+
const codeVerifier = this.generateCodeVerifier()
|
|
16
|
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier)
|
|
17
|
+
const state = this.generateState()
|
|
18
|
+
|
|
19
|
+
// Store PKCE values for later use
|
|
20
|
+
sessionStorage.setItem('oauth_code_verifier', codeVerifier)
|
|
21
|
+
sessionStorage.setItem('oauth_state', state)
|
|
22
|
+
|
|
23
|
+
const authUrl = this.buildAuthUrl(codeChallenge, state)
|
|
24
|
+
|
|
25
|
+
// Check if we're already in a redirect callback
|
|
26
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
27
|
+
const code = urlParams.get('code')
|
|
28
|
+
const returnedState = urlParams.get('state')
|
|
29
|
+
const error = urlParams.get('error')
|
|
30
|
+
|
|
31
|
+
if (error) {
|
|
32
|
+
throw new Error(`OAuth error: ${error}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (code && returnedState) {
|
|
36
|
+
// Validate state
|
|
37
|
+
const storedState = sessionStorage.getItem('oauth_state')
|
|
38
|
+
if (storedState !== returnedState) {
|
|
39
|
+
throw new Error('Invalid state parameter')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Exchange code for tokens
|
|
43
|
+
const tokens = await this.exchangeCodeForTokens(code, codeVerifier)
|
|
44
|
+
await this.storeTokens(tokens)
|
|
45
|
+
|
|
46
|
+
// Clean up URL
|
|
47
|
+
window.history.replaceState({}, document.title, window.location.pathname)
|
|
48
|
+
|
|
49
|
+
return { tokens }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Redirect to authorization server
|
|
53
|
+
window.location.href = authUrl
|
|
54
|
+
|
|
55
|
+
// This won't be reached due to redirect, but TypeScript needs it
|
|
56
|
+
throw new Error('Authorization flow initiated')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async refresh(refreshToken: string): Promise<OAuthResult> {
|
|
60
|
+
const tokenEndpoint = this.config.tokenEndpoint || `${this.config.issuer}/token`
|
|
61
|
+
|
|
62
|
+
const body = new URLSearchParams({
|
|
63
|
+
grant_type: 'refresh_token',
|
|
64
|
+
refresh_token: refreshToken,
|
|
65
|
+
client_id: this.config.clientId,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Note: Client secrets should NEVER be in client-side code
|
|
69
|
+
// This is for public clients only (PKCE provides security)
|
|
70
|
+
|
|
71
|
+
const response = await fetch(tokenEndpoint, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
75
|
+
...this.config.customHeaders,
|
|
76
|
+
},
|
|
77
|
+
body: body.toString(),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw new Error(`Token refresh failed: ${response.statusText}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = await response.json()
|
|
85
|
+
const tokens = this.parseTokenResponse(data)
|
|
86
|
+
await this.storeTokens(tokens)
|
|
87
|
+
|
|
88
|
+
return { tokens }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async revoke(token: string): Promise<void> {
|
|
92
|
+
const revokeEndpoint = this.config.revocationEndpoint || `${this.config.issuer}/revoke`
|
|
93
|
+
|
|
94
|
+
const body = new URLSearchParams({
|
|
95
|
+
token,
|
|
96
|
+
client_id: this.config.clientId,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Note: Client secrets should NEVER be in client-side code
|
|
100
|
+
// Using public client flow only
|
|
101
|
+
|
|
102
|
+
const response = await fetch(revokeEndpoint, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
106
|
+
...this.config.customHeaders,
|
|
107
|
+
},
|
|
108
|
+
body: body.toString(),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (!response.ok && response.status !== 404) {
|
|
112
|
+
throw new Error(`Token revocation failed: ${response.statusText}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async logout(): Promise<void> {
|
|
117
|
+
const tokens = await this.getStoredTokens()
|
|
118
|
+
|
|
119
|
+
if (tokens?.accessToken) {
|
|
120
|
+
try {
|
|
121
|
+
await this.revoke(tokens.accessToken)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.warn('Failed to revoke access token:', error)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (tokens?.refreshToken) {
|
|
128
|
+
try {
|
|
129
|
+
await this.revoke(tokens.refreshToken)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn('Failed to revoke refresh token:', error)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await this.clearStoredTokens()
|
|
136
|
+
|
|
137
|
+
// Redirect to end session endpoint if available
|
|
138
|
+
if (this.config.endSessionEndpoint && tokens?.idToken) {
|
|
139
|
+
const endSessionUrl = new URL(this.config.endSessionEndpoint)
|
|
140
|
+
endSessionUrl.searchParams.set('id_token_hint', tokens.idToken)
|
|
141
|
+
endSessionUrl.searchParams.set('post_logout_redirect_uri', this.config.redirectUrl)
|
|
142
|
+
window.location.href = endSessionUrl.toString()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getStoredTokens(): Promise<OAuthTokens | null> {
|
|
147
|
+
const stored = await this.storage.getItem(this.storageKey)
|
|
148
|
+
if (!stored) return null
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const tokens = JSON.parse(stored)
|
|
152
|
+
return {
|
|
153
|
+
...tokens,
|
|
154
|
+
expiresAt: tokens.expiresAt ? new Date(tokens.expiresAt) : undefined,
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async clearStoredTokens(): Promise<void> {
|
|
162
|
+
await this.storage.removeItem(this.storageKey)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async storeTokens(tokens: OAuthTokens): Promise<void> {
|
|
166
|
+
const serializable = {
|
|
167
|
+
...tokens,
|
|
168
|
+
expiresAt: tokens.expiresAt?.toISOString(),
|
|
169
|
+
}
|
|
170
|
+
await this.storage.setItem(this.storageKey, JSON.stringify(serializable))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private buildAuthUrl(codeChallenge: string, state: string): string {
|
|
174
|
+
const authEndpoint = this.config.authorizationEndpoint || `${this.config.issuer}/auth`
|
|
175
|
+
const url = new URL(authEndpoint)
|
|
176
|
+
|
|
177
|
+
url.searchParams.set('response_type', 'code')
|
|
178
|
+
url.searchParams.set('client_id', this.config.clientId)
|
|
179
|
+
url.searchParams.set('redirect_uri', this.config.redirectUrl)
|
|
180
|
+
url.searchParams.set('code_challenge', codeChallenge)
|
|
181
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
182
|
+
url.searchParams.set('state', state)
|
|
183
|
+
|
|
184
|
+
if (this.config.scopes?.length) {
|
|
185
|
+
url.searchParams.set('scope', this.config.scopes.join(' '))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Add additional parameters
|
|
189
|
+
if (this.config.additionalParameters) {
|
|
190
|
+
Object.entries(this.config.additionalParameters).forEach(([key, value]) => {
|
|
191
|
+
url.searchParams.set(key, value)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return url.toString()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<OAuthTokens> {
|
|
199
|
+
const tokenEndpoint = this.config.tokenEndpoint || `${this.config.issuer}/token`
|
|
200
|
+
|
|
201
|
+
const body = new URLSearchParams({
|
|
202
|
+
grant_type: 'authorization_code',
|
|
203
|
+
code,
|
|
204
|
+
redirect_uri: this.config.redirectUrl,
|
|
205
|
+
client_id: this.config.clientId,
|
|
206
|
+
code_verifier: codeVerifier,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Note: Client secrets should NEVER be in client-side code
|
|
210
|
+
// PKCE provides security for public clients
|
|
211
|
+
|
|
212
|
+
const response = await fetch(tokenEndpoint, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
216
|
+
...this.config.customHeaders,
|
|
217
|
+
},
|
|
218
|
+
body: body.toString(),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
const errorData = await response.text()
|
|
223
|
+
throw new Error(`Token exchange failed: ${response.statusText} - ${errorData}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const data = await response.json()
|
|
227
|
+
return this.parseTokenResponse(data)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private parseTokenResponse(data: any): OAuthTokens {
|
|
231
|
+
const expiresAt = data.expires_in
|
|
232
|
+
? new Date(Date.now() + data.expires_in * 1000)
|
|
233
|
+
: undefined
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
accessToken: data.access_token,
|
|
237
|
+
refreshToken: data.refresh_token,
|
|
238
|
+
idToken: data.id_token,
|
|
239
|
+
tokenType: data.token_type || 'Bearer',
|
|
240
|
+
expiresAt,
|
|
241
|
+
scopes: data.scope ? data.scope.split(' ') : undefined,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private generateCodeVerifier(): string {
|
|
246
|
+
const array = new Uint8Array(32)
|
|
247
|
+
crypto.getRandomValues(array)
|
|
248
|
+
return btoa(String.fromCharCode.apply(null, Array.from(array)))
|
|
249
|
+
.replace(/\+/g, '-')
|
|
250
|
+
.replace(/\//g, '_')
|
|
251
|
+
.replace(/=/g, '')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async generateCodeChallenge(verifier: string): Promise<string> {
|
|
255
|
+
const encoder = new TextEncoder()
|
|
256
|
+
const data = encoder.encode(verifier)
|
|
257
|
+
const digest = await crypto.subtle.digest('SHA-256', data)
|
|
258
|
+
return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(digest))))
|
|
259
|
+
.replace(/\+/g, '-')
|
|
260
|
+
.replace(/\//g, '_')
|
|
261
|
+
.replace(/=/g, '')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private generateState(): string {
|
|
265
|
+
const array = new Uint8Array(16)
|
|
266
|
+
crypto.getRandomValues(array)
|
|
267
|
+
return btoa(String.fromCharCode.apply(null, Array.from(array)))
|
|
268
|
+
.replace(/\+/g, '-')
|
|
269
|
+
.replace(/\//g, '_')
|
|
270
|
+
.replace(/=/g, '')
|
|
271
|
+
}
|
|
272
|
+
}
|