@idealyst/oauth-client 1.0.69 → 1.0.70
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 +172 -182
- package/package.json +3 -9
- package/src/examples/simple-oauth-example.ts +60 -0
- package/src/index.native.ts +10 -0
- package/src/index.ts +8 -53
- package/src/index.web.ts +10 -0
- package/src/oauth-client.native.ts +130 -0
- package/src/oauth-client.web.ts +83 -0
- package/src/types.ts +8 -36
- package/src/examples/google-example.ts +0 -108
- package/src/native-client.ts +0 -159
- package/src/storage.ts +0 -60
- package/src/web-client.ts +0 -272
package/src/web-client.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
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
|
-
}
|