@strav/social 1.0.0-alpha.36 → 1.0.0-alpha.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/social",
3
- "version": "1.0.0-alpha.36",
3
+ "version": "1.0.0-alpha.39",
4
4
  "description": "Strav social-login module — provider-agnostic OAuth/OIDC client. Normalized profile + token DTOs, state + PKCE helpers, capability gating, multi-provider routing. Line / Google / Facebook adapters ship as subpath imports (`@strav/social/line`, `@strav/social/google`, `@strav/social/facebook`).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,6 +10,7 @@
10
10
  "./line": "./src/drivers/line/index.ts",
11
11
  "./google": "./src/drivers/google/index.ts",
12
12
  "./facebook": "./src/drivers/facebook/index.ts",
13
+ "./oauth2": "./src/drivers/oauth2/index.ts",
13
14
  "./tenanted": "./src/tenanted/index.ts"
14
15
  },
15
16
  "files": [
@@ -23,9 +24,9 @@
23
24
  "access": "public"
24
25
  },
25
26
  "dependencies": {
26
- "@strav/database": "1.0.0-alpha.36",
27
- "@strav/http": "1.0.0-alpha.36",
28
- "@strav/kernel": "1.0.0-alpha.36"
27
+ "@strav/database": "1.0.0-alpha.39",
28
+ "@strav/http": "1.0.0-alpha.39",
29
+ "@strav/kernel": "1.0.0-alpha.39"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "@types/bun": ">=1.3.14"
@@ -33,6 +33,8 @@
33
33
  */
34
34
 
35
35
  import type { OAuthTokens, SocialProfile } from '../../dto/index.ts'
36
+ import { mapOidcUserInfo } from '../../dto/oidc_user_info.ts'
37
+ import { decodeJwtClaims } from '../../jwt.ts'
36
38
  import type { SocialCapability } from '../../social_capabilities.ts'
37
39
  import type {
38
40
  AuthorizeInput,
@@ -85,13 +87,7 @@ interface UserInfoResponse {
85
87
  family_name?: string
86
88
  picture?: string
87
89
  locale?: string
88
- }
89
-
90
- interface JwtPayload {
91
- email?: string
92
- email_verified?: boolean
93
- sub?: string
94
- [k: string]: unknown
90
+ [claim: string]: unknown
95
91
  }
96
92
 
97
93
  /**
@@ -243,20 +239,7 @@ export class GoogleSocialDriver implements SocialDriver {
243
239
  )
244
240
  }
245
241
  const u = (await res.json()) as UserInfoResponse
246
- return {
247
- id: u.sub,
248
- provider: PROVIDER,
249
- ...(u.email ? { email: u.email } : {}),
250
- ...(u.email_verified !== undefined ? { emailVerified: u.email_verified } : {}),
251
- ...(u.name ? { name: u.name } : {}),
252
- ...(u.picture ? { avatarUrl: u.picture } : {}),
253
- ...(u.locale ? { locale: u.locale } : {}),
254
- metadata: {
255
- ...(u.given_name ? { givenName: u.given_name } : {}),
256
- ...(u.family_name ? { familyName: u.family_name } : {}),
257
- },
258
- raw: u,
259
- }
242
+ return mapOidcUserInfo(u, { provider: PROVIDER })
260
243
  }
261
244
 
262
245
  async refresh(input: RefreshInput): Promise<OAuthTokens> {
@@ -448,25 +431,6 @@ export class GoogleSocialDriver implements SocialDriver {
448
431
  * is discarded immediately after exchange).
449
432
  */
450
433
  export function emailFromGoogleIdToken(idToken: string): string | null {
451
- const segments = idToken.split('.')
452
- if (segments.length !== 3) {
453
- throw new InvalidTokenError(
454
- 'emailFromGoogleIdToken: id_token does not have 3 segments.',
455
- )
456
- }
457
- const payload = decodeJwtSegment(segments[1]!)
458
- return typeof payload.email === 'string' ? payload.email : null
459
- }
460
-
461
- function decodeJwtSegment(segment: string): JwtPayload {
462
- const pad = segment.length % 4
463
- const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
464
- const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
465
- try {
466
- return JSON.parse(atob(b64)) as JwtPayload
467
- } catch (cause) {
468
- throw new InvalidTokenError('decodeJwtSegment: failed to parse JWT segment.', {
469
- cause,
470
- })
471
- }
434
+ const claims = decodeJwtClaims(idToken)
435
+ return typeof claims.email === 'string' ? claims.email : null
472
436
  }
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { OAuthTokens, SocialProfile } from '../../dto/index.ts'
33
+ import { decodeJwtClaims } from '../../jwt.ts'
33
34
  import type { SocialCapability } from '../../social_capabilities.ts'
34
35
  import type {
35
36
  AuthorizeInput,
@@ -81,15 +82,6 @@ interface ProfileResponse {
81
82
  statusMessage?: string
82
83
  }
83
84
 
84
- interface JwtPayload {
85
- email?: string
86
- email_verified?: boolean
87
- name?: string
88
- picture?: string
89
- sub?: string
90
- [k: string]: unknown
91
- }
92
-
93
85
  export class LineSocialDriver implements SocialDriver {
94
86
  readonly name = PROVIDER
95
87
  readonly instanceName: string
@@ -286,25 +278,6 @@ export class LineSocialDriver implements SocialDriver {
286
278
  * Line's `/oauth2/v2.1/verify` endpoint with the id_token.
287
279
  */
288
280
  export function emailFromLineIdToken(idToken: string): string | null {
289
- const segments = idToken.split('.')
290
- if (segments.length !== 3) {
291
- throw new InvalidTokenError('emailFromLineIdToken: id_token does not have 3 segments.')
292
- }
293
- const payload = decodeJwtSegment(segments[1]!)
294
- return typeof payload.email === 'string' ? payload.email : null
295
- }
296
-
297
- function decodeJwtSegment(segment: string): JwtPayload {
298
- // base64url → base64 → string
299
- const pad = segment.length % 4
300
- const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
301
- const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
302
- try {
303
- const json = atob(b64)
304
- return JSON.parse(json) as JwtPayload
305
- } catch (cause) {
306
- throw new InvalidTokenError('decodeJwtSegment: failed to parse JWT segment.', {
307
- cause,
308
- })
309
- }
281
+ const claims = decodeJwtClaims(idToken)
282
+ return typeof claims.email === 'string' ? claims.email : null
310
283
  }
@@ -0,0 +1,16 @@
1
+ // `@strav/social/oauth2` — generic OAuth2/OIDC driver.
2
+
3
+ export type {
4
+ OAuth2Capabilities,
5
+ OAuth2DriverConfig,
6
+ OAuth2Endpoints,
7
+ OAuth2HttpMethod,
8
+ OAuth2Pkce,
9
+ OAuth2ProfileSpec,
10
+ OAuth2ScopesSpec,
11
+ } from './oauth2_config.ts'
12
+ export {
13
+ OAuth2SocialDriver,
14
+ type OAuth2DriverOptions,
15
+ } from './oauth2_driver.ts'
16
+ export { OAuth2SocialProvider } from './oauth2_provider.ts'
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `OAuth2DriverConfig` — config shape for the generic OAuth2/OIDC driver.
3
+ *
4
+ * The provider is fully data-driven: endpoints + a profile mapper + the
5
+ * usual capability flags. Apps add a new provider (GitHub, Discord,
6
+ * Microsoft, Slack, Twitter/X, GitLab, …) in a few lines of config instead
7
+ * of writing a new driver file.
8
+ */
9
+
10
+ import type { ProviderConfig } from '../../types.ts'
11
+ import type { SocialProfile } from '../../dto/social_profile.ts'
12
+
13
+ export interface OAuth2Endpoints {
14
+ authorize: string
15
+ token: string
16
+ /**
17
+ * URL fetched by `driver.profile(accessToken)`. Required unless
18
+ * `profile.source === 'idToken'` — in which case the driver maps from
19
+ * the `id_token` JWT instead.
20
+ */
21
+ userInfo?: string
22
+ revoke?: string
23
+ introspect?: string
24
+ }
25
+
26
+ export type OAuth2HttpMethod = 'GET' | 'POST'
27
+
28
+ export interface OAuth2ProfileSpec {
29
+ /**
30
+ * Where the profile comes from:
31
+ * - `'userInfo'` — call `endpoints.userInfo` with the access token (OIDC default)
32
+ * - `'idToken'` — decode the OIDC `id_token` JWT (no network call)
33
+ */
34
+ source?: 'userInfo' | 'idToken'
35
+ /** HTTP method for the userInfo call. Default `'GET'`. */
36
+ method?: OAuth2HttpMethod
37
+ /**
38
+ * How to authenticate the userInfo call.
39
+ * - `'bearer'` (default) — `Authorization: Bearer <token>`
40
+ * - `'queryToken'` — `?access_token=<token>`
41
+ * - `'noneSigned'` — no auth header; only for endpoints that accept the id_token in the body
42
+ */
43
+ auth?: 'bearer' | 'queryToken' | 'noneSigned'
44
+ /**
45
+ * Extra query params appended to the userInfo URL. Useful for providers
46
+ * that require `?fields=...` selection (e.g. Facebook Graph, GitHub).
47
+ */
48
+ query?: Record<string, string>
49
+ /** Extra request headers. */
50
+ headers?: Record<string, string>
51
+ /**
52
+ * Translate the raw response into the framework's `SocialProfile`. When
53
+ * omitted, the driver uses `mapOidcUserInfo({ provider })` — works for
54
+ * any OIDC `/userinfo` shape.
55
+ */
56
+ map?: (raw: unknown) => SocialProfile
57
+ }
58
+
59
+ export interface OAuth2ScopesSpec {
60
+ /** Scopes sent on `authorize()` when the caller doesn't pass `scopes`. */
61
+ default?: readonly string[]
62
+ /** Scopes the driver claims to support, surfaced via `driver.availableScopes`. */
63
+ available?: readonly string[]
64
+ /** Delimiter between scopes in the authorize URL. Default `' '`. Facebook uses `,`. */
65
+ delimiter?: string
66
+ }
67
+
68
+ export interface OAuth2Pkce {
69
+ /** PKCE mode: `'off'` / `'support'` (default — on unless `extra.no_pkce='1'`) / `'required'`. */
70
+ mode?: 'off' | 'support' | 'required'
71
+ }
72
+
73
+ export interface OAuth2Capabilities {
74
+ refresh?: boolean
75
+ revoke?: boolean
76
+ introspect?: boolean
77
+ openid?: boolean
78
+ }
79
+
80
+ /**
81
+ * Full config shape. Apps include this under
82
+ * `config.social.providers[name]` with `driver: 'oauth2'`.
83
+ */
84
+ export interface OAuth2DriverConfig extends ProviderConfig {
85
+ driver: 'oauth2'
86
+ clientId: string
87
+ clientSecret: string
88
+ endpoints: OAuth2Endpoints
89
+ scopes?: OAuth2ScopesSpec
90
+ pkce?: OAuth2Pkce
91
+ profile?: OAuth2ProfileSpec
92
+ capabilities?: OAuth2Capabilities
93
+ /**
94
+ * Extra query parameters merged into the authorize URL. Useful for
95
+ * provider-specific opts like `access_type=offline`, `prompt=consent`.
96
+ */
97
+ authorizeExtra?: Record<string, string>
98
+ /** Auth style for the token endpoint: `'body'` (default) or `'basic'`. */
99
+ clientAuth?: 'body' | 'basic'
100
+ /** Injectable for tests. */
101
+ fetch?: typeof fetch
102
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * `OAuth2SocialDriver` — config-driven OAuth2/OIDC driver.
3
+ *
4
+ * Implements the full `SocialDriver` contract from endpoints + a small
5
+ * `OAuth2ProfileSpec`. Drop-in replacement for hand-written drivers when
6
+ * the provider speaks vanilla OAuth2 — GitHub, Discord, Microsoft, Slack,
7
+ * GitLab, Twitch, LinkedIn, etc.
8
+ *
9
+ * Defaults are OIDC-shaped:
10
+ * - PKCE: `support` (on by default, opt-out via `extra.no_pkce='1'`)
11
+ * - profile source: `userInfo` (Bearer auth, JSON response)
12
+ * - profile mapping: `mapOidcUserInfo({ provider })`
13
+ *
14
+ * Override per-provider in config — see `OAuth2DriverConfig`.
15
+ */
16
+
17
+ import type { OAuthTokens, SocialProfile } from '../../dto/index.ts'
18
+ import { mapOidcUserInfo, type OidcUserInfo } from '../../dto/oidc_user_info.ts'
19
+ import { decodeJwtClaims } from '../../jwt.ts'
20
+ import { codeChallengeFor, randomCodeVerifier, randomState } from '../../pkce.ts'
21
+ import type { SocialCapability } from '../../social_capabilities.ts'
22
+ import type {
23
+ AuthorizeInput,
24
+ AuthorizeResult,
25
+ ExchangeInput,
26
+ RefreshInput,
27
+ SocialDriver,
28
+ } from '../../social_driver.ts'
29
+ import {
30
+ InvalidTokenError,
31
+ OAuthExchangeError,
32
+ ProviderUnsupportedError,
33
+ SocialConfigError,
34
+ SocialProviderError,
35
+ StateMismatchError,
36
+ } from '../../social_error.ts'
37
+ import type { OAuth2DriverConfig } from './oauth2_config.ts'
38
+
39
+ export interface OAuth2DriverOptions {
40
+ instanceName: string
41
+ /** App-chosen name surfaced via `driver.name` (default `'oauth2'`). */
42
+ providerName?: string
43
+ config: OAuth2DriverConfig
44
+ }
45
+
46
+ interface TokenResponse {
47
+ access_token: string
48
+ expires_in?: number
49
+ id_token?: string
50
+ refresh_token?: string
51
+ scope?: string
52
+ token_type?: string
53
+ }
54
+
55
+ export class OAuth2SocialDriver implements SocialDriver {
56
+ readonly name: string
57
+ readonly instanceName: string
58
+ readonly capabilities: ReadonlySet<SocialCapability>
59
+ readonly availableScopes: readonly string[]
60
+
61
+ private readonly config: OAuth2DriverConfig
62
+ private readonly fetchFn: typeof fetch
63
+ private readonly scopeDelimiter: string
64
+
65
+ constructor(options: OAuth2DriverOptions) {
66
+ this.instanceName = options.instanceName
67
+ // Surface the configured provider name everywhere — falls back to the
68
+ // generic `'oauth2'` only when no provider name was supplied.
69
+ this.name = options.providerName ?? 'oauth2'
70
+ this.config = options.config
71
+ this.fetchFn = options.config.fetch ?? fetch
72
+ this.scopeDelimiter = options.config.scopes?.delimiter ?? ' '
73
+ this.availableScopes = options.config.scopes?.available ?? options.config.scopes?.default ?? []
74
+ this.capabilities = new Set(this.computeCapabilities())
75
+
76
+ if (!this.config.endpoints.authorize || !this.config.endpoints.token) {
77
+ throw new SocialConfigError(
78
+ `OAuth2SocialDriver: provider "${this.instanceName}" is missing the authorize or token endpoint.`,
79
+ )
80
+ }
81
+ }
82
+
83
+ async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
84
+ const state = input.state ?? randomState()
85
+ const pkceMode = this.config.pkce?.mode ?? 'support'
86
+
87
+ const optOut = input.extra?.['no_pkce'] === '1'
88
+ const wantsPkce = pkceMode === 'required' || (pkceMode === 'support' && !optOut)
89
+ const codeVerifier = wantsPkce ? input.codeVerifier ?? randomCodeVerifier() : undefined
90
+ if (pkceMode === 'required' && !codeVerifier) {
91
+ throw new SocialConfigError(
92
+ `OAuth2SocialDriver: provider "${this.instanceName}" requires PKCE but no code verifier was generated.`,
93
+ )
94
+ }
95
+ const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
96
+
97
+ const scopes = input.scopes ?? this.config.scopes?.default ?? []
98
+ const params = new URLSearchParams({
99
+ response_type: 'code',
100
+ client_id: this.config.clientId,
101
+ redirect_uri: input.redirectUri,
102
+ state,
103
+ ...(scopes.length > 0 ? { scope: scopes.join(this.scopeDelimiter) } : {}),
104
+ ...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
105
+ ...(this.config.authorizeExtra ?? {}),
106
+ ...(input.extra ?? {}),
107
+ })
108
+ // Strip the framework's PKCE opt-out flag — it must not leak upstream.
109
+ params.delete('no_pkce')
110
+
111
+ return {
112
+ url: `${this.config.endpoints.authorize}?${params.toString()}`,
113
+ state,
114
+ ...(codeVerifier ? { codeVerifier } : {}),
115
+ }
116
+ }
117
+
118
+ async exchange(input: ExchangeInput): Promise<OAuthTokens> {
119
+ if (input.expectedState !== undefined && input.state !== input.expectedState) {
120
+ throw new StateMismatchError()
121
+ }
122
+ const body = new URLSearchParams({
123
+ grant_type: 'authorization_code',
124
+ code: input.code,
125
+ redirect_uri: input.redirectUri,
126
+ ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
127
+ })
128
+ const res = await this.tokenRequest(body)
129
+ if (!res.ok) {
130
+ const text = await res.text()
131
+ throw new OAuthExchangeError(
132
+ `OAuth2SocialDriver(${this.name}).exchange: token endpoint returned ${res.status}.`,
133
+ { context: { status: res.status, body: text } },
134
+ )
135
+ }
136
+ return this.toOAuthTokens((await res.json()) as TokenResponse)
137
+ }
138
+
139
+ async profile(accessToken: string): Promise<SocialProfile> {
140
+ const spec = this.config.profile ?? {}
141
+ if (spec.source === 'idToken') {
142
+ throw new SocialConfigError(
143
+ `OAuth2SocialDriver(${this.name}).profile: profile.source='idToken' requires calling profileFromIdToken(idToken) instead.`,
144
+ )
145
+ }
146
+ const url = this.config.endpoints.userInfo
147
+ if (!url) {
148
+ throw new SocialConfigError(
149
+ `OAuth2SocialDriver(${this.name}).profile: endpoints.userInfo is not configured.`,
150
+ )
151
+ }
152
+ const method = spec.method ?? 'GET'
153
+ const auth = spec.auth ?? 'bearer'
154
+ const finalUrl = this.appendQuery(url, {
155
+ ...(spec.query ?? {}),
156
+ ...(auth === 'queryToken' ? { access_token: accessToken } : {}),
157
+ })
158
+ const headers: Record<string, string> = { ...(spec.headers ?? {}) }
159
+ if (auth === 'bearer') headers['authorization'] = `Bearer ${accessToken}`
160
+
161
+ const res = await this.fetchFn(finalUrl, { method, headers })
162
+ if (res.status === 401) {
163
+ throw new InvalidTokenError(
164
+ `OAuth2SocialDriver(${this.name}).profile: access token rejected.`,
165
+ )
166
+ }
167
+ if (!res.ok) {
168
+ const text = await res.text()
169
+ throw new SocialProviderError(
170
+ `OAuth2SocialDriver(${this.name}).profile: userinfo endpoint returned ${res.status}.`,
171
+ {
172
+ provider: this.name,
173
+ operation: 'profile',
174
+ context: { status: res.status, body: text },
175
+ },
176
+ )
177
+ }
178
+ const raw = await res.json()
179
+ return this.mapProfile(raw, spec.map)
180
+ }
181
+
182
+ /**
183
+ * Decode the OIDC id_token and map its claims into a `SocialProfile`.
184
+ * Use this on providers configured with `profile.source: 'idToken'` —
185
+ * common when the provider issues a short-lived access token whose only
186
+ * job is the userinfo call you don't want to make.
187
+ */
188
+ profileFromIdToken(idToken: string): SocialProfile {
189
+ const claims = decodeJwtClaims(idToken)
190
+ const spec = this.config.profile ?? {}
191
+ return this.mapProfile(claims, spec.map)
192
+ }
193
+
194
+ async refresh(input: RefreshInput): Promise<OAuthTokens> {
195
+ if (!this.capabilities.has('tokens.refresh')) {
196
+ throw new ProviderUnsupportedError(this.name, 'tokens.refresh')
197
+ }
198
+ const body = new URLSearchParams({
199
+ grant_type: 'refresh_token',
200
+ refresh_token: input.refreshToken,
201
+ ...(input.scopes ? { scope: input.scopes.join(this.scopeDelimiter) } : {}),
202
+ })
203
+ const res = await this.tokenRequest(body)
204
+ if (res.status === 400 || res.status === 401) {
205
+ const text = await res.text()
206
+ throw new InvalidTokenError(
207
+ `OAuth2SocialDriver(${this.name}).refresh: refresh token rejected.`,
208
+ { context: { status: res.status, body: text } },
209
+ )
210
+ }
211
+ if (!res.ok) {
212
+ const text = await res.text()
213
+ throw new SocialProviderError(
214
+ `OAuth2SocialDriver(${this.name}).refresh: token endpoint returned ${res.status}.`,
215
+ {
216
+ provider: this.name,
217
+ operation: 'refresh',
218
+ context: { status: res.status, body: text },
219
+ },
220
+ )
221
+ }
222
+ const tokens = this.toOAuthTokens((await res.json()) as TokenResponse)
223
+ // Many providers omit the refresh token on rotation — preserve the
224
+ // caller's current one so they don't lose offline access.
225
+ if (!tokens.refreshToken) tokens.refreshToken = input.refreshToken
226
+ return tokens
227
+ }
228
+
229
+ async revoke(token: string): Promise<void> {
230
+ if (!this.capabilities.has('tokens.revoke')) {
231
+ throw new ProviderUnsupportedError(this.name, 'tokens.revoke')
232
+ }
233
+ const url = this.config.endpoints.revoke
234
+ if (!url) {
235
+ throw new SocialConfigError(
236
+ `OAuth2SocialDriver(${this.name}).revoke: endpoints.revoke is not configured.`,
237
+ )
238
+ }
239
+ const body = new URLSearchParams({ token })
240
+ const headers: Record<string, string> = {
241
+ 'content-type': 'application/x-www-form-urlencoded',
242
+ }
243
+ if (this.config.clientAuth === 'basic') {
244
+ headers['authorization'] = this.basicAuthHeader()
245
+ } else {
246
+ body.set('client_id', this.config.clientId)
247
+ body.set('client_secret', this.config.clientSecret)
248
+ }
249
+ const res = await this.fetchFn(url, { method: 'POST', headers, body })
250
+ if (!res.ok) {
251
+ const text = await res.text()
252
+ throw new SocialProviderError(
253
+ `OAuth2SocialDriver(${this.name}).revoke: revoke endpoint returned ${res.status}.`,
254
+ {
255
+ provider: this.name,
256
+ operation: 'revoke',
257
+ context: { status: res.status, body: text },
258
+ },
259
+ )
260
+ }
261
+ }
262
+
263
+ // ─── Internals ────────────────────────────────────────────────────────
264
+
265
+ private mapProfile(
266
+ raw: unknown,
267
+ override: ((raw: unknown) => SocialProfile) | undefined,
268
+ ): SocialProfile {
269
+ if (override) return override(raw)
270
+ return mapOidcUserInfo(raw as OidcUserInfo, { provider: this.name })
271
+ }
272
+
273
+ private async tokenRequest(body: URLSearchParams): Promise<Response> {
274
+ const headers: Record<string, string> = {
275
+ 'content-type': 'application/x-www-form-urlencoded',
276
+ accept: 'application/json',
277
+ }
278
+ if (this.config.clientAuth === 'basic') {
279
+ headers['authorization'] = this.basicAuthHeader()
280
+ } else {
281
+ body.set('client_id', this.config.clientId)
282
+ body.set('client_secret', this.config.clientSecret)
283
+ }
284
+ return this.fetchFn(this.config.endpoints.token, { method: 'POST', headers, body })
285
+ }
286
+
287
+ private basicAuthHeader(): string {
288
+ const cred = `${this.config.clientId}:${this.config.clientSecret}`
289
+ return `Basic ${btoa(cred)}`
290
+ }
291
+
292
+ private computeCapabilities(): SocialCapability[] {
293
+ const out: SocialCapability[] = ['tokens.exchange']
294
+ const caps = this.config.capabilities ?? {}
295
+ const pkce = this.config.pkce?.mode ?? 'support'
296
+ if (pkce === 'support') out.push('pkce.support')
297
+ if (pkce === 'required') {
298
+ out.push('pkce.support')
299
+ out.push('pkce.required')
300
+ }
301
+ if (caps.openid) out.push('openid')
302
+ if (caps.refresh !== false) out.push('tokens.refresh') // default on
303
+ if (caps.revoke && this.config.endpoints.revoke) out.push('tokens.revoke')
304
+ if (caps.introspect && this.config.endpoints.introspect) out.push('tokens.introspect')
305
+
306
+ // Optimistic profile capabilities — apps should narrow with their own
307
+ // mapper if the provider truly can't fulfil one of these claims. The
308
+ // generic driver can't introspect the userinfo schema, so it advertises
309
+ // the OIDC defaults and trusts callers to override.
310
+ if (this.config.endpoints.userInfo || this.config.profile?.source === 'idToken') {
311
+ out.push('profile.id', 'profile.email', 'profile.emailVerified', 'profile.name')
312
+ out.push('profile.avatar', 'profile.locale')
313
+ }
314
+ if (this.config.scopes?.available && this.config.scopes.available.length > 0) {
315
+ out.push('scopes.discoverable')
316
+ }
317
+ return out
318
+ }
319
+
320
+ private appendQuery(url: string, params: Record<string, string>): string {
321
+ const entries = Object.entries(params)
322
+ if (entries.length === 0) return url
323
+ const u = new URL(url)
324
+ for (const [k, v] of entries) u.searchParams.set(k, v)
325
+ return u.toString()
326
+ }
327
+
328
+ private toOAuthTokens(t: TokenResponse): OAuthTokens {
329
+ const expiresAt =
330
+ typeof t.expires_in === 'number' ? new Date(Date.now() + t.expires_in * 1000) : undefined
331
+ return {
332
+ accessToken: t.access_token,
333
+ ...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
334
+ ...(t.id_token ? { idToken: t.id_token } : {}),
335
+ ...(expiresAt ? { expiresAt } : {}),
336
+ ...(t.scope ? { scope: t.scope } : {}),
337
+ tokenType: t.token_type ?? 'Bearer',
338
+ raw: t,
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `OAuth2SocialProvider` — registers the `'oauth2'` driver factory on
3
+ * `SocialManager`. Apps add this to `bootstrap/providers.ts` once and
4
+ * then configure any number of providers under that driver in
5
+ * `config.social.providers`.
6
+ */
7
+
8
+ import { type Application, ServiceProvider } from '@strav/kernel'
9
+ import { SocialManager } from '../../social_manager.ts'
10
+ import type { OAuth2DriverConfig } from './oauth2_config.ts'
11
+ import { OAuth2SocialDriver } from './oauth2_driver.ts'
12
+
13
+ export class OAuth2SocialProvider extends ServiceProvider {
14
+ override readonly name = 'social.oauth2'
15
+ override readonly dependencies = ['social']
16
+
17
+ override register(app: Application): void {
18
+ app.resolve(SocialManager).extend('oauth2', ({ instanceName, config }) => {
19
+ return new OAuth2SocialDriver({
20
+ instanceName,
21
+ providerName: instanceName,
22
+ config: config as OAuth2DriverConfig,
23
+ })
24
+ })
25
+ }
26
+ }
package/src/dto/index.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  /** Barrel for the social DTOs. */
2
2
 
3
3
  export type { OAuthTokens } from './oauth_tokens.ts'
4
+ export {
5
+ mapOidcUserInfo,
6
+ type MapOidcUserInfoOptions,
7
+ type OidcUserInfo,
8
+ } from './oidc_user_info.ts'
4
9
  export type { SocialProfile } from './social_profile.ts'
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `mapOidcUserInfo` — translate an OIDC `/userinfo` response (or any
3
+ * provider that happens to use OIDC-standard claim names) into the
4
+ * framework's normalised `SocialProfile`.
5
+ *
6
+ * Used by the Google driver and the generic OAuth2/OIDC driver. Apps with
7
+ * non-OIDC providers (GitHub uses `id` + `avatar_url`, X uses `data.id`,
8
+ * etc.) supply a provider-specific mapper via the driver config —
9
+ * see `OAuth2DriverConfig.profile.map`.
10
+ *
11
+ * Claim mapping (in priority order — first present wins):
12
+ *
13
+ * id ← `sub`
14
+ * email ← `email`
15
+ * emailVerified ← `email_verified`
16
+ * name ← `name` ‖ `given_name + family_name`
17
+ * avatarUrl ← `picture`
18
+ * locale ← `locale`
19
+ *
20
+ * Unmapped claims survive on `metadata` (`given_name` / `family_name`
21
+ * specifically — they're often useful and not part of the top-level
22
+ * normalised shape).
23
+ */
24
+
25
+ import type { SocialProfile } from './social_profile.ts'
26
+
27
+ export interface OidcUserInfo {
28
+ sub?: string
29
+ email?: string
30
+ email_verified?: boolean
31
+ name?: string
32
+ given_name?: string
33
+ family_name?: string
34
+ picture?: string
35
+ locale?: string
36
+ [claim: string]: unknown
37
+ }
38
+
39
+ export interface MapOidcUserInfoOptions {
40
+ /** Provider name to stamp onto `profile.provider`. */
41
+ provider: string
42
+ /**
43
+ * Override the id source. Default `sub`. Used when a provider returns
44
+ * its native user id under a different key (e.g. `id`).
45
+ */
46
+ idClaim?: string
47
+ }
48
+
49
+ export function mapOidcUserInfo(
50
+ raw: OidcUserInfo,
51
+ options: MapOidcUserInfoOptions,
52
+ ): SocialProfile {
53
+ const idClaim = options.idClaim ?? 'sub'
54
+ const idValue = raw[idClaim]
55
+ if (typeof idValue !== 'string' || idValue.length === 0) {
56
+ throw new Error(
57
+ `mapOidcUserInfo: missing "${idClaim}" claim on userinfo response for "${options.provider}".`,
58
+ )
59
+ }
60
+
61
+ const name = raw.name ?? joinName(raw.given_name, raw.family_name)
62
+ const metadata: Record<string, unknown> = {}
63
+ if (raw.given_name) metadata['givenName'] = raw.given_name
64
+ if (raw.family_name) metadata['familyName'] = raw.family_name
65
+
66
+ const profile: SocialProfile = {
67
+ id: idValue,
68
+ provider: options.provider,
69
+ metadata,
70
+ raw,
71
+ }
72
+ if (raw.email) profile.email = raw.email
73
+ if (raw.email_verified !== undefined) profile.emailVerified = raw.email_verified
74
+ if (name) profile.name = name
75
+ if (raw.picture) profile.avatarUrl = raw.picture
76
+ if (raw.locale) profile.locale = raw.locale
77
+ return profile
78
+ }
79
+
80
+ function joinName(given?: string, family?: string): string | undefined {
81
+ const parts = [given, family].filter((p): p is string => Boolean(p))
82
+ if (parts.length === 0) return undefined
83
+ return parts.join(' ')
84
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  // unique + user_id index
20
20
 
21
21
  export type * from './dto/index.ts'
22
+ export { mapOidcUserInfo } from './dto/oidc_user_info.ts'
22
23
  export { MockDriver, type MockDriverOptions, unsupported } from './drivers/index.ts'
23
24
  export {
24
25
  applySocialAccountMigration,
@@ -30,6 +31,11 @@ export {
30
31
  SocialAccountRepository,
31
32
  socialAccountSchema,
32
33
  } from './ledger/index.ts'
34
+ export {
35
+ decodeJwtClaims,
36
+ emailFromIdToken,
37
+ type JwtClaims,
38
+ } from './jwt.ts'
33
39
  export {
34
40
  codeChallengeFor,
35
41
  randomCodeVerifier,
package/src/jwt.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Minimal JWT helpers — decode-only.
3
+ *
4
+ * Used across drivers (Google + Line today, plus the generic OIDC driver)
5
+ * to pull claims out of `id_token` JWTs. **Does not** verify the JWS
6
+ * signature: the token arrives over TLS direct from the provider's token
7
+ * endpoint in the same response as the access token, so the trust boundary
8
+ * is identical. Apps with stricter posture verify against the provider's
9
+ * JWKS or call the provider's `tokeninfo` / `verify` endpoint.
10
+ */
11
+
12
+ import { InvalidTokenError } from './social_error.ts'
13
+
14
+ export interface JwtClaims {
15
+ /** Subject — the provider's user id. */
16
+ sub?: string
17
+ /** Issuer URL. */
18
+ iss?: string
19
+ /** Audience — typically the client id. */
20
+ aud?: string | readonly string[]
21
+ /** Issued-at and expiration in seconds since the epoch. */
22
+ iat?: number
23
+ exp?: number
24
+ email?: string
25
+ email_verified?: boolean
26
+ name?: string
27
+ picture?: string
28
+ locale?: string
29
+ [claim: string]: unknown
30
+ }
31
+
32
+ /**
33
+ * Decode every claim segment of a `<header>.<payload>.<signature>` JWT.
34
+ * Throws `InvalidTokenError` on structural problems (wrong segment count,
35
+ * unparsable base64url, unparsable JSON).
36
+ */
37
+ export function decodeJwtClaims(idToken: string): JwtClaims {
38
+ const segments = idToken.split('.')
39
+ if (segments.length !== 3) {
40
+ throw new InvalidTokenError('decodeJwtClaims: id_token does not have 3 segments.')
41
+ }
42
+ return decodeJwtSegment(segments[1] ?? '')
43
+ }
44
+
45
+ /**
46
+ * Convenience for the common "did the provider include email?" path.
47
+ * Returns the string when present, `null` when the claim is missing,
48
+ * throws when the token itself is malformed.
49
+ */
50
+ export function emailFromIdToken(idToken: string): string | null {
51
+ const claims = decodeJwtClaims(idToken)
52
+ return typeof claims.email === 'string' ? claims.email : null
53
+ }
54
+
55
+ function decodeJwtSegment(segment: string): JwtClaims {
56
+ if (segment.length === 0) {
57
+ throw new InvalidTokenError('decodeJwtClaims: payload segment is empty.')
58
+ }
59
+ const pad = segment.length % 4
60
+ const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
61
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
62
+ try {
63
+ return JSON.parse(atob(b64)) as JwtClaims
64
+ } catch (cause) {
65
+ throw new InvalidTokenError('decodeJwtClaims: failed to parse JWT segment.', {
66
+ cause,
67
+ })
68
+ }
69
+ }
@@ -27,6 +27,7 @@ import type {
27
27
  SocialDriverFactory,
28
28
  } from './social_driver.ts'
29
29
  import type { OAuthTokens, SocialProfile } from './dto/index.ts'
30
+ import type { SocialCapability } from './social_capabilities.ts'
30
31
  import {
31
32
  SocialConfigError,
32
33
  UnknownProviderError,
@@ -92,6 +93,16 @@ export class SocialManager {
92
93
  this.drivers.set(instanceName, driver)
93
94
  }
94
95
 
96
+ /**
97
+ * Shorthand for `manager.use(name).capabilities.has(capability)`. Lets UI
98
+ * code gate buttons / scope pickers without spelling out the driver
99
+ * resolution. Throws `UnknownProviderError` if the provider is not
100
+ * configured.
101
+ */
102
+ supports(name: string, capability: SocialCapability): boolean {
103
+ return this.use(name).capabilities.has(capability)
104
+ }
105
+
95
106
  // ─── Resource accessors (route to the default driver) ────────────────
96
107
 
97
108
  authorize(input: AuthorizeInput): Promise<AuthorizeResult> {