@strav/social 1.0.0-alpha.38 → 1.0.0-alpha.40
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 +5 -4
- package/src/drivers/google/google_driver.ts +6 -42
- package/src/drivers/line/line_driver.ts +3 -30
- package/src/drivers/oauth2/index.ts +16 -0
- package/src/drivers/oauth2/oauth2_config.ts +102 -0
- package/src/drivers/oauth2/oauth2_driver.ts +341 -0
- package/src/drivers/oauth2/oauth2_provider.ts +26 -0
- package/src/dto/index.ts +5 -0
- package/src/dto/oidc_user_info.ts +84 -0
- package/src/index.ts +6 -0
- package/src/jwt.ts +69 -0
- package/src/social_manager.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/social",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.40",
|
|
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.
|
|
27
|
-
"@strav/http": "1.0.0-alpha.
|
|
28
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
27
|
+
"@strav/database": "1.0.0-alpha.40",
|
|
28
|
+
"@strav/http": "1.0.0-alpha.40",
|
|
29
|
+
"@strav/kernel": "1.0.0-alpha.40"
|
|
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
|
|
452
|
-
|
|
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
|
|
290
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/social_manager.ts
CHANGED
|
@@ -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> {
|