@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 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
+ }