@strav/social 0.4.31 → 1.0.0-alpha.24
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 +23 -16
- package/src/drivers/facebook/facebook_config.ts +68 -0
- package/src/drivers/facebook/facebook_driver.ts +321 -0
- package/src/drivers/facebook/facebook_provider.ts +29 -0
- package/src/drivers/facebook/index.ts +12 -0
- package/src/drivers/google/google_config.ts +44 -0
- package/src/drivers/google/google_driver.ts +317 -0
- package/src/drivers/google/google_provider.ts +33 -0
- package/src/drivers/google/index.ts +12 -0
- package/src/drivers/index.ts +2 -0
- package/src/drivers/line/index.ts +12 -0
- package/src/drivers/line/line_config.ts +47 -0
- package/src/drivers/line/line_driver.ts +310 -0
- package/src/drivers/line/line_provider.ts +34 -0
- package/src/drivers/mock_driver.ts +170 -0
- package/src/drivers/unsupported.ts +17 -0
- package/src/dto/index.ts +4 -0
- package/src/dto/oauth_tokens.ts +26 -0
- package/src/dto/social_profile.ts +37 -0
- package/src/index.ts +61 -14
- package/src/ledger/apply_social_account_migration.ts +66 -0
- package/src/ledger/index.ts +12 -0
- package/src/ledger/social_account.ts +32 -0
- package/src/ledger/social_account_repository.ts +203 -0
- package/src/ledger/social_account_schema.ts +75 -0
- package/src/pkce.ts +63 -0
- package/src/social_capabilities.ts +35 -0
- package/src/social_driver.ts +143 -0
- package/src/social_error.ts +155 -0
- package/src/social_manager.ts +92 -74
- package/src/social_provider.ts +41 -7
- package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_social_account.ts +30 -0
- package/src/tenanted/tenanted_social_account_repository.ts +136 -0
- package/src/tenanted/tenanted_social_account_schema.ts +44 -0
- package/src/types.ts +15 -43
- package/CHANGELOG.md +0 -19
- package/README.md +0 -78
- package/src/abstract_provider.ts +0 -182
- package/src/helpers.ts +0 -31
- package/src/providers/discord_provider.ts +0 -59
- package/src/providers/facebook_provider.ts +0 -69
- package/src/providers/github_provider.ts +0 -75
- package/src/providers/google_provider.ts +0 -51
- package/src/providers/line_provider.ts +0 -73
- package/src/providers/linkedin_provider.ts +0 -51
- package/src/schema.ts +0 -13
- package/src/social_account.ts +0 -238
- package/stubs/config/social.ts +0 -22
- package/stubs/schemas/social_account.ts +0 -13
- package/tsconfig.json +0 -5
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LineSocialDriver` — Line Login v2.1 implementation.
|
|
3
|
+
*
|
|
4
|
+
* Line is SEA-load-bearing: dominant chat + login in Thailand
|
|
5
|
+
* and Japan, growing across SEA more generally. Strav defaults
|
|
6
|
+
* to Line as the primary social adapter; Google + Facebook
|
|
7
|
+
* round out the international + global-reach options.
|
|
8
|
+
*
|
|
9
|
+
* Line specifics worth knowing:
|
|
10
|
+
*
|
|
11
|
+
* - **Scopes**: `profile` (always free), `openid` (free —
|
|
12
|
+
* returns an id_token), `email` (requires Line approval on
|
|
13
|
+
* the channel, then granted per-user via the consent screen).
|
|
14
|
+
* - **PKCE**: supported, not required.
|
|
15
|
+
* - **Email**: only available by decoding the id_token JWT
|
|
16
|
+
* when `openid email` was both requested AND granted. Line
|
|
17
|
+
* does NOT include email on the `/v2/profile` REST response.
|
|
18
|
+
* - **id_token verification**: the driver decodes JWT claims
|
|
19
|
+
* for the `email` field but does NOT verify the JWS
|
|
20
|
+
* signature. The token arrives over TLS direct from Line's
|
|
21
|
+
* token endpoint, so the trust boundary is the same as the
|
|
22
|
+
* access token. Apps that want signature verification
|
|
23
|
+
* against Line's JWKS run it themselves or use the
|
|
24
|
+
* `/oauth2/v2.1/verify` endpoint via `driver.client`.
|
|
25
|
+
* - **No locale field on profile** — apps that need it pass
|
|
26
|
+
* `ui_locales` on authorize and store the app-side choice
|
|
27
|
+
* alongside the user record.
|
|
28
|
+
*
|
|
29
|
+
* Token / refresh / revoke all use the standard OAuth2 endpoints.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { OAuthTokens, SocialProfile } from '../../dto/index.ts'
|
|
33
|
+
import type { SocialCapability } from '../../social_capabilities.ts'
|
|
34
|
+
import type {
|
|
35
|
+
AuthorizeInput,
|
|
36
|
+
AuthorizeResult,
|
|
37
|
+
ExchangeInput,
|
|
38
|
+
RefreshInput,
|
|
39
|
+
SocialDriver,
|
|
40
|
+
} from '../../social_driver.ts'
|
|
41
|
+
import {
|
|
42
|
+
InvalidTokenError,
|
|
43
|
+
OAuthExchangeError,
|
|
44
|
+
SocialProviderError,
|
|
45
|
+
StateMismatchError,
|
|
46
|
+
} from '../../social_error.ts'
|
|
47
|
+
import { codeChallengeFor, randomCodeVerifier, randomState } from '../../pkce.ts'
|
|
48
|
+
import { LINE_ENDPOINTS, type LineProviderConfig } from './line_config.ts'
|
|
49
|
+
|
|
50
|
+
const PROVIDER = 'line'
|
|
51
|
+
|
|
52
|
+
const CAPS: readonly SocialCapability[] = [
|
|
53
|
+
'openid', 'pkce.support',
|
|
54
|
+
'profile.id', 'profile.email', 'profile.emailVerified',
|
|
55
|
+
'profile.name', 'profile.avatar',
|
|
56
|
+
// No `profile.locale` — Line doesn't return locale on the profile API.
|
|
57
|
+
'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
|
|
58
|
+
'scopes.discoverable',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const SCOPES: readonly string[] = ['openid', 'profile', 'email']
|
|
62
|
+
|
|
63
|
+
export interface LineDriverOptions {
|
|
64
|
+
instanceName: string
|
|
65
|
+
config: LineProviderConfig
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TokenResponse {
|
|
69
|
+
access_token: string
|
|
70
|
+
expires_in: number
|
|
71
|
+
id_token?: string
|
|
72
|
+
refresh_token?: string
|
|
73
|
+
scope?: string
|
|
74
|
+
token_type: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ProfileResponse {
|
|
78
|
+
userId: string
|
|
79
|
+
displayName: string
|
|
80
|
+
pictureUrl?: string
|
|
81
|
+
statusMessage?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
export class LineSocialDriver implements SocialDriver {
|
|
94
|
+
readonly name = PROVIDER
|
|
95
|
+
readonly instanceName: string
|
|
96
|
+
readonly capabilities: ReadonlySet<SocialCapability> = new Set(CAPS)
|
|
97
|
+
readonly availableScopes = SCOPES
|
|
98
|
+
|
|
99
|
+
private readonly config: LineProviderConfig
|
|
100
|
+
private readonly fetchFn: typeof fetch
|
|
101
|
+
private readonly endpoints: { authorize: string; token: string; profile: string; revoke: string; verify: string }
|
|
102
|
+
|
|
103
|
+
constructor(options: LineDriverOptions) {
|
|
104
|
+
this.instanceName = options.instanceName
|
|
105
|
+
this.config = options.config
|
|
106
|
+
this.fetchFn = options.config.fetch ?? fetch
|
|
107
|
+
this.endpoints = { ...LINE_ENDPOINTS, ...(options.config.endpoints ?? {}) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
111
|
+
const state = input.state ?? randomState()
|
|
112
|
+
// PKCE: Line supports but doesn't require. We default to
|
|
113
|
+
// including it (defence in depth for callback hijacking on
|
|
114
|
+
// mobile + SPA flows; harmless on server-side flows). Apps
|
|
115
|
+
// that explicitly pass `codeVerifier: undefined` opt out by
|
|
116
|
+
// sending `extra.no_pkce: '1'`.
|
|
117
|
+
const optOut = input.extra?.no_pkce === '1'
|
|
118
|
+
const codeVerifier = optOut
|
|
119
|
+
? undefined
|
|
120
|
+
: input.codeVerifier ?? randomCodeVerifier()
|
|
121
|
+
const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
|
|
122
|
+
|
|
123
|
+
const scopes = input.scopes ?? ['profile']
|
|
124
|
+
const params = new URLSearchParams({
|
|
125
|
+
response_type: 'code',
|
|
126
|
+
client_id: this.config.clientId,
|
|
127
|
+
redirect_uri: input.redirectUri,
|
|
128
|
+
scope: scopes.join(' '),
|
|
129
|
+
state,
|
|
130
|
+
...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
|
|
131
|
+
...(this.config.uiLocales ? { ui_locales: this.config.uiLocales } : {}),
|
|
132
|
+
...(input.extra ?? {}),
|
|
133
|
+
})
|
|
134
|
+
// Don't leak the framework helper through to Line.
|
|
135
|
+
params.delete('no_pkce')
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
url: `${this.endpoints.authorize}?${params.toString()}`,
|
|
139
|
+
state,
|
|
140
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
145
|
+
if (input.expectedState !== undefined && input.state !== input.expectedState) {
|
|
146
|
+
throw new StateMismatchError()
|
|
147
|
+
}
|
|
148
|
+
const body = new URLSearchParams({
|
|
149
|
+
grant_type: 'authorization_code',
|
|
150
|
+
code: input.code,
|
|
151
|
+
redirect_uri: input.redirectUri,
|
|
152
|
+
client_id: this.config.clientId,
|
|
153
|
+
client_secret: this.config.clientSecret,
|
|
154
|
+
...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
|
|
155
|
+
})
|
|
156
|
+
const res = await this.fetchFn(this.endpoints.token, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
159
|
+
body,
|
|
160
|
+
})
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
const text = await res.text()
|
|
163
|
+
throw new OAuthExchangeError(
|
|
164
|
+
`LineSocialDriver.exchange: token endpoint returned ${res.status}.`,
|
|
165
|
+
{ context: { status: res.status, body: text } },
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const json = (await res.json()) as TokenResponse
|
|
169
|
+
return this.toOAuthTokens(json)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async profile(accessToken: string): Promise<SocialProfile> {
|
|
173
|
+
const res = await this.fetchFn(this.endpoints.profile, {
|
|
174
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
175
|
+
})
|
|
176
|
+
if (res.status === 401) {
|
|
177
|
+
throw new InvalidTokenError('LineSocialDriver.profile: access token rejected.')
|
|
178
|
+
}
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const text = await res.text()
|
|
181
|
+
throw new SocialProviderError(
|
|
182
|
+
`LineSocialDriver.profile: profile endpoint returned ${res.status}.`,
|
|
183
|
+
{ provider: PROVIDER, operation: 'profile', context: { status: res.status, body: text } },
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const p = (await res.json()) as ProfileResponse
|
|
187
|
+
return {
|
|
188
|
+
id: p.userId,
|
|
189
|
+
provider: PROVIDER,
|
|
190
|
+
...(p.displayName ? { name: p.displayName } : {}),
|
|
191
|
+
...(p.pictureUrl ? { avatarUrl: p.pictureUrl } : {}),
|
|
192
|
+
// Email is NOT on /v2/profile. Apps that need it decoded the
|
|
193
|
+
// id_token at exchange time and stored it on the user record.
|
|
194
|
+
metadata: p.statusMessage ? { statusMessage: p.statusMessage } : {},
|
|
195
|
+
raw: p,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
200
|
+
const body = new URLSearchParams({
|
|
201
|
+
grant_type: 'refresh_token',
|
|
202
|
+
refresh_token: input.refreshToken,
|
|
203
|
+
client_id: this.config.clientId,
|
|
204
|
+
client_secret: this.config.clientSecret,
|
|
205
|
+
})
|
|
206
|
+
const res = await this.fetchFn(this.endpoints.token, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
209
|
+
body,
|
|
210
|
+
})
|
|
211
|
+
if (res.status === 400 || res.status === 401) {
|
|
212
|
+
const text = await res.text()
|
|
213
|
+
throw new InvalidTokenError(
|
|
214
|
+
`LineSocialDriver.refresh: refresh token rejected.`,
|
|
215
|
+
{ context: { status: res.status, body: text } },
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const text = await res.text()
|
|
220
|
+
throw new SocialProviderError(
|
|
221
|
+
`LineSocialDriver.refresh: token endpoint returned ${res.status}.`,
|
|
222
|
+
{ provider: PROVIDER, operation: 'refresh', context: { status: res.status, body: text } },
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return this.toOAuthTokens((await res.json()) as TokenResponse)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async revoke(token: string): Promise<void> {
|
|
229
|
+
const body = new URLSearchParams({
|
|
230
|
+
access_token: token,
|
|
231
|
+
client_id: this.config.clientId,
|
|
232
|
+
client_secret: this.config.clientSecret,
|
|
233
|
+
})
|
|
234
|
+
const res = await this.fetchFn(this.endpoints.revoke, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
237
|
+
body,
|
|
238
|
+
})
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
const text = await res.text()
|
|
241
|
+
throw new SocialProviderError(
|
|
242
|
+
`LineSocialDriver.revoke: revoke endpoint returned ${res.status}.`,
|
|
243
|
+
{ provider: PROVIDER, operation: 'revoke', context: { status: res.status, body: text } },
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Internals ────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
private toOAuthTokens(t: TokenResponse): OAuthTokens {
|
|
251
|
+
const expiresAt =
|
|
252
|
+
typeof t.expires_in === 'number'
|
|
253
|
+
? new Date(Date.now() + t.expires_in * 1000)
|
|
254
|
+
: undefined
|
|
255
|
+
const tokens: OAuthTokens = {
|
|
256
|
+
accessToken: t.access_token,
|
|
257
|
+
...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
|
|
258
|
+
...(t.id_token ? { idToken: t.id_token } : {}),
|
|
259
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
260
|
+
...(t.scope ? { scope: t.scope } : {}),
|
|
261
|
+
tokenType: t.token_type ?? 'Bearer',
|
|
262
|
+
raw: t,
|
|
263
|
+
}
|
|
264
|
+
return tokens
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract `email` from a Line id_token. Returns the email
|
|
270
|
+
* string when present, `null` when the id_token has no email
|
|
271
|
+
* claim (typical when `email` scope wasn't requested or
|
|
272
|
+
* granted), and throws when the token is structurally invalid.
|
|
273
|
+
*
|
|
274
|
+
* Apps that need the email at signup time decode the id_token
|
|
275
|
+
* right after `exchange()`. The framework deliberately keeps
|
|
276
|
+
* this as a side helper rather than auto-decoding inside
|
|
277
|
+
* `exchange()` — the OIDC id_token has many claims; surfacing
|
|
278
|
+
* email-only matches the most common app need, anything else
|
|
279
|
+
* stays on `tokens.idToken` for apps to parse themselves.
|
|
280
|
+
*
|
|
281
|
+
* **Security note**: this does NOT verify the JWS signature.
|
|
282
|
+
* The id_token arrives over TLS direct from Line's token
|
|
283
|
+
* endpoint in the same response as the access token; trusting
|
|
284
|
+
* the payload at that point has the same posture as trusting
|
|
285
|
+
* the access token. Apps that want full verification call
|
|
286
|
+
* Line's `/oauth2/v2.1/verify` endpoint with the id_token.
|
|
287
|
+
*/
|
|
288
|
+
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
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LineSocialProvider` — `ServiceProvider` that registers the
|
|
3
|
+
* Line driver factory on the `SocialManager`.
|
|
4
|
+
*
|
|
5
|
+
* List AFTER `SocialProvider` in `bootstrap/providers.ts`. The
|
|
6
|
+
* factory is invoked lazily on first `social.use(name)`; misconfigured
|
|
7
|
+
* Line credentials surface on first use, not at boot. Apps that
|
|
8
|
+
* want fail-fast call `social.use('line')` from their own `boot()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
12
|
+
import { SocialConfigError } from '../../social_error.ts'
|
|
13
|
+
import { SocialManager } from '../../social_manager.ts'
|
|
14
|
+
import type { LineProviderConfig } from './line_config.ts'
|
|
15
|
+
import { LineSocialDriver } from './line_driver.ts'
|
|
16
|
+
|
|
17
|
+
export class LineSocialProvider extends ServiceProvider {
|
|
18
|
+
override readonly name = 'social-line'
|
|
19
|
+
override readonly dependencies = ['social']
|
|
20
|
+
|
|
21
|
+
override register(app: Application): void {
|
|
22
|
+
const manager = app.resolve(SocialManager)
|
|
23
|
+
manager.extend('line', ({ instanceName, config }) => {
|
|
24
|
+
const cfg = config as LineProviderConfig
|
|
25
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
26
|
+
throw new SocialConfigError(
|
|
27
|
+
`LineSocialProvider: \`clientId\` and \`clientSecret\` are required for provider "${instanceName}".`,
|
|
28
|
+
{ context: { instanceName } },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return new LineSocialDriver({ instanceName, config: cfg })
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MockDriver` — in-memory reference implementation. Used by
|
|
3
|
+
* unit tests + as the canonical contract example for new
|
|
4
|
+
* adapters.
|
|
5
|
+
*
|
|
6
|
+
* Round-trips tokens + profiles through plain Maps. PKCE +
|
|
7
|
+
* state verification mirror what real drivers enforce so apps
|
|
8
|
+
* exercising the full flow against the mock catch bugs in
|
|
9
|
+
* their state handling before they hit a real provider.
|
|
10
|
+
*
|
|
11
|
+
* Capabilities: full by default — every flag declared. Tests
|
|
12
|
+
* that exercise `ProviderUnsupportedError` paths construct the
|
|
13
|
+
* mock with a narrowed `capabilities` set.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ulid } from '@strav/kernel'
|
|
17
|
+
import {
|
|
18
|
+
InvalidTokenError,
|
|
19
|
+
OAuthExchangeError,
|
|
20
|
+
StateMismatchError,
|
|
21
|
+
} from '../social_error.ts'
|
|
22
|
+
import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
|
|
23
|
+
import type { SocialCapability } from '../social_capabilities.ts'
|
|
24
|
+
import { codeChallengeFor, randomCodeVerifier, randomState } from '../pkce.ts'
|
|
25
|
+
import type {
|
|
26
|
+
AuthorizeInput,
|
|
27
|
+
AuthorizeResult,
|
|
28
|
+
ExchangeInput,
|
|
29
|
+
RefreshInput,
|
|
30
|
+
SocialDriver,
|
|
31
|
+
} from '../social_driver.ts'
|
|
32
|
+
|
|
33
|
+
const ALL_CAPS: readonly SocialCapability[] = [
|
|
34
|
+
'openid', 'pkce.support',
|
|
35
|
+
'profile.id', 'profile.email', 'profile.emailVerified',
|
|
36
|
+
'profile.name', 'profile.avatar', 'profile.locale',
|
|
37
|
+
'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
|
|
38
|
+
'scopes.discoverable',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export interface MockDriverOptions {
|
|
42
|
+
instanceName?: string
|
|
43
|
+
capabilities?: ReadonlySet<SocialCapability>
|
|
44
|
+
/** Profile returned by `profile(accessToken)` calls. Tests override to assert specific shapes. */
|
|
45
|
+
profileFor?(accessToken: string): SocialProfile
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PendingFlow {
|
|
49
|
+
state: string
|
|
50
|
+
codeVerifier?: string
|
|
51
|
+
scopes: readonly string[]
|
|
52
|
+
redirectUri: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class MockDriver implements SocialDriver {
|
|
56
|
+
readonly name = 'mock'
|
|
57
|
+
readonly instanceName: string
|
|
58
|
+
readonly capabilities: ReadonlySet<SocialCapability>
|
|
59
|
+
readonly availableScopes: readonly string[] = ['openid', 'profile', 'email']
|
|
60
|
+
|
|
61
|
+
private readonly pending = new Map<string, PendingFlow>()
|
|
62
|
+
private readonly issuedTokens = new Map<string, { code: string; refreshToken: string }>()
|
|
63
|
+
private readonly profileForFn: (accessToken: string) => SocialProfile
|
|
64
|
+
|
|
65
|
+
constructor(options: MockDriverOptions = {}) {
|
|
66
|
+
this.instanceName = options.instanceName ?? 'mock'
|
|
67
|
+
this.capabilities = options.capabilities ?? new Set(ALL_CAPS)
|
|
68
|
+
this.profileForFn =
|
|
69
|
+
options.profileFor ??
|
|
70
|
+
((token: string): SocialProfile => ({
|
|
71
|
+
id: `mock_${token.slice(0, 8)}`,
|
|
72
|
+
provider: this.name,
|
|
73
|
+
email: `${token.slice(0, 6)}@mock.test`,
|
|
74
|
+
emailVerified: true,
|
|
75
|
+
name: 'Mock User',
|
|
76
|
+
avatarUrl: 'https://mock.test/avatar.png',
|
|
77
|
+
locale: 'en',
|
|
78
|
+
metadata: {},
|
|
79
|
+
raw: { mock: true },
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
84
|
+
const state = input.state ?? randomState()
|
|
85
|
+
const codeVerifier =
|
|
86
|
+
input.codeVerifier ??
|
|
87
|
+
(this.capabilities.has('pkce.support') ? randomCodeVerifier() : undefined)
|
|
88
|
+
const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
|
|
89
|
+
this.pending.set(state, {
|
|
90
|
+
state,
|
|
91
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
92
|
+
scopes: input.scopes ?? ['profile'],
|
|
93
|
+
redirectUri: input.redirectUri,
|
|
94
|
+
})
|
|
95
|
+
const params = new URLSearchParams({
|
|
96
|
+
response_type: 'code',
|
|
97
|
+
client_id: 'mock_client',
|
|
98
|
+
redirect_uri: input.redirectUri,
|
|
99
|
+
scope: (input.scopes ?? ['profile']).join(' '),
|
|
100
|
+
state,
|
|
101
|
+
...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
|
|
102
|
+
...(input.extra ?? {}),
|
|
103
|
+
})
|
|
104
|
+
return {
|
|
105
|
+
url: `https://mock.test/oauth/authorize?${params.toString()}`,
|
|
106
|
+
state,
|
|
107
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
112
|
+
if (input.expectedState !== undefined && input.state !== input.expectedState) {
|
|
113
|
+
throw new StateMismatchError()
|
|
114
|
+
}
|
|
115
|
+
const flow = input.state ? this.pending.get(input.state) : undefined
|
|
116
|
+
if (!flow) {
|
|
117
|
+
throw new OAuthExchangeError('MockDriver: no pending authorize for this state.')
|
|
118
|
+
}
|
|
119
|
+
if (flow.codeVerifier && flow.codeVerifier !== input.codeVerifier) {
|
|
120
|
+
throw new OAuthExchangeError('MockDriver: PKCE verifier mismatch.')
|
|
121
|
+
}
|
|
122
|
+
this.pending.delete(input.state!)
|
|
123
|
+
const accessToken = `mock_at_${ulid()}`
|
|
124
|
+
const refreshToken = `mock_rt_${ulid()}`
|
|
125
|
+
this.issuedTokens.set(accessToken, { code: input.code, refreshToken })
|
|
126
|
+
return {
|
|
127
|
+
accessToken,
|
|
128
|
+
refreshToken,
|
|
129
|
+
...(flow.scopes.includes('openid') ? { idToken: `mock_id_${ulid()}` } : {}),
|
|
130
|
+
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
131
|
+
scope: flow.scopes.join(' '),
|
|
132
|
+
tokenType: 'Bearer',
|
|
133
|
+
raw: { mock: true, code: input.code },
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async profile(accessToken: string): Promise<SocialProfile> {
|
|
138
|
+
if (!this.issuedTokens.has(accessToken)) {
|
|
139
|
+
throw new InvalidTokenError(`MockDriver.profile: unknown access token.`)
|
|
140
|
+
}
|
|
141
|
+
return this.profileForFn(accessToken)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
145
|
+
// Find the access token whose refresh token matches.
|
|
146
|
+
const found = [...this.issuedTokens.entries()].find(
|
|
147
|
+
([, v]) => v.refreshToken === input.refreshToken,
|
|
148
|
+
)
|
|
149
|
+
if (!found) {
|
|
150
|
+
throw new InvalidTokenError('MockDriver.refresh: unknown refresh token.')
|
|
151
|
+
}
|
|
152
|
+
// Rotate.
|
|
153
|
+
this.issuedTokens.delete(found[0])
|
|
154
|
+
const accessToken = `mock_at_${ulid()}`
|
|
155
|
+
const newRefresh = `mock_rt_${ulid()}`
|
|
156
|
+
this.issuedTokens.set(accessToken, { code: found[1].code, refreshToken: newRefresh })
|
|
157
|
+
return {
|
|
158
|
+
accessToken,
|
|
159
|
+
refreshToken: newRefresh,
|
|
160
|
+
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
161
|
+
scope: (input.scopes ?? []).join(' '),
|
|
162
|
+
tokenType: 'Bearer',
|
|
163
|
+
raw: { mock: true },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async revoke(token: string): Promise<void> {
|
|
168
|
+
this.issuedTokens.delete(token)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unsupported(provider, operation, reason?)` — drivers stub
|
|
3
|
+
* out methods they can't fulfil. Returns a function that
|
|
4
|
+
* throws `ProviderUnsupportedError` synchronously.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ProviderUnsupportedError } from '../social_error.ts'
|
|
8
|
+
|
|
9
|
+
export function unsupported(
|
|
10
|
+
provider: string,
|
|
11
|
+
operation: string,
|
|
12
|
+
reason?: string,
|
|
13
|
+
): (...args: unknown[]) => never {
|
|
14
|
+
return () => {
|
|
15
|
+
throw new ProviderUnsupportedError(provider, operation, reason ? { reason } : {})
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/dto/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OAuthTokens` — normalized token payload from a successful
|
|
3
|
+
* code exchange or refresh. Apps persist these against their
|
|
4
|
+
* user record (encrypted via `@strav/kernel`'s cipher).
|
|
5
|
+
*
|
|
6
|
+
* `idToken` is set only for OIDC providers (Google, Line as
|
|
7
|
+
* OpenID Connect). Plain-OAuth2 providers (Facebook) leave it
|
|
8
|
+
* undefined.
|
|
9
|
+
*
|
|
10
|
+
* `expiresAt` is provider-derived (`expires_in` seconds → wall
|
|
11
|
+
* clock). Drivers compute it at exchange time so apps don't
|
|
12
|
+
* need to track when the call was made.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface OAuthTokens {
|
|
16
|
+
accessToken: string
|
|
17
|
+
/** Available only when the user granted offline access (Google) or the provider issues refresh tokens by default (Line). */
|
|
18
|
+
refreshToken?: string
|
|
19
|
+
/** OIDC id_token (JWT). Present on `openid` flows. Apps that already trust the access token usually ignore this. */
|
|
20
|
+
idToken?: string
|
|
21
|
+
expiresAt?: Date
|
|
22
|
+
/** Space-separated scope string the provider granted (may be narrower than what was requested). */
|
|
23
|
+
scope?: string
|
|
24
|
+
tokenType: string
|
|
25
|
+
raw: unknown
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialProfile` — normalized user-info shape across providers.
|
|
3
|
+
* The native provider object is on `.raw`; apps reach there for
|
|
4
|
+
* fields the framework doesn't normalize (Line's `pictureUrl`
|
|
5
|
+
* vs Google's `picture` collapse to `avatarUrl`; Line's
|
|
6
|
+
* `displayName` collapses to `name`).
|
|
7
|
+
*
|
|
8
|
+
* Provider divergence the framework intentionally surfaces:
|
|
9
|
+
*
|
|
10
|
+
* - `email` is optional. Line gives it only when the `email`
|
|
11
|
+
* scope is requested AND the user accepts; Facebook gives it
|
|
12
|
+
* only if the app is approved for `email`. Apps that need it
|
|
13
|
+
* check capability AND check `profile.email`.
|
|
14
|
+
*
|
|
15
|
+
* - `emailVerified` is true only when the provider asserts it.
|
|
16
|
+
* Google + Line always assert; Facebook never does — apps
|
|
17
|
+
* verify themselves.
|
|
18
|
+
*
|
|
19
|
+
* - `id` is the provider-native subject id. Globally unique
|
|
20
|
+
* within the provider's namespace, not across providers.
|
|
21
|
+
* The `(provider, id)` pair is what `social_account` rows
|
|
22
|
+
* key on (slice 8.5).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SocialProfile {
|
|
26
|
+
/** Provider-native user id (`sub` in OIDC, `userId` in Line, `id` in Facebook). */
|
|
27
|
+
id: string
|
|
28
|
+
/** Driver name — `'line'` / `'google'` / `'facebook'`. */
|
|
29
|
+
provider: string
|
|
30
|
+
email?: string
|
|
31
|
+
emailVerified?: boolean
|
|
32
|
+
name?: string
|
|
33
|
+
avatarUrl?: string
|
|
34
|
+
locale?: string
|
|
35
|
+
metadata: Record<string, unknown>
|
|
36
|
+
raw: unknown
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
// Public API of `@strav/social`.
|
|
2
|
+
//
|
|
3
|
+
// V1: provider-agnostic OAuth/OIDC client — normalized profile +
|
|
4
|
+
// token DTOs + multi-provider routing + state + PKCE helpers.
|
|
5
|
+
// Composes with `@strav/kernel` for the container and
|
|
6
|
+
// `@strav/http` (no direct import; for the eventual route helpers).
|
|
7
|
+
//
|
|
8
|
+
// Drivers ship as subpath imports:
|
|
9
|
+
// `@strav/social/line`, `@strav/social/google`,
|
|
10
|
+
// `@strav/social/facebook`. The `MockDriver` in `./drivers`
|
|
11
|
+
// is for tests + as the reference contract.
|
|
12
|
+
//
|
|
13
|
+
// Account-linking schema lives in `./ledger/`:
|
|
14
|
+
// - `social_account` tenanted table (tokens encrypted via
|
|
15
|
+
// kernel's cipher)
|
|
16
|
+
// - `SocialAccountRepository` with connect / disconnect /
|
|
17
|
+
// find{ByUser,ByProviderIdentity} helpers
|
|
18
|
+
// - `applySocialAccountMigration` for the table + composite
|
|
19
|
+
// unique + user_id index
|
|
2
20
|
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
21
|
+
export type * from './dto/index.ts'
|
|
22
|
+
export { MockDriver, type MockDriverOptions, unsupported } from './drivers/index.ts'
|
|
23
|
+
export {
|
|
24
|
+
applySocialAccountMigration,
|
|
25
|
+
type ApplySocialAccountMigrationOptions,
|
|
26
|
+
type ConnectInput,
|
|
27
|
+
type DisconnectInput,
|
|
28
|
+
SocialAccount,
|
|
29
|
+
SocialAccountAlreadyLinkedError,
|
|
30
|
+
SocialAccountRepository,
|
|
31
|
+
socialAccountSchema,
|
|
32
|
+
} from './ledger/index.ts'
|
|
33
|
+
export {
|
|
34
|
+
codeChallengeFor,
|
|
35
|
+
randomCodeVerifier,
|
|
36
|
+
randomState,
|
|
37
|
+
} from './pkce.ts'
|
|
38
|
+
export type { SocialCapability } from './social_capabilities.ts'
|
|
39
|
+
export type {
|
|
40
|
+
AuthorizeInput,
|
|
41
|
+
AuthorizeResult,
|
|
42
|
+
ExchangeInput,
|
|
43
|
+
RefreshInput,
|
|
44
|
+
SocialDriver,
|
|
45
|
+
SocialDriverFactory,
|
|
46
|
+
} from './social_driver.ts'
|
|
47
|
+
export {
|
|
48
|
+
InvalidTokenError,
|
|
49
|
+
OAuthExchangeError,
|
|
50
|
+
ProviderUnsupportedError,
|
|
51
|
+
SocialConfigError,
|
|
52
|
+
SocialError,
|
|
53
|
+
SocialProviderError,
|
|
54
|
+
StateMismatchError,
|
|
55
|
+
UnknownProviderError,
|
|
56
|
+
} from './social_error.ts'
|
|
57
|
+
export {
|
|
58
|
+
SocialManager,
|
|
59
|
+
type SocialManagerOptions,
|
|
60
|
+
} from './social_manager.ts'
|
|
61
|
+
export { SocialProvider } from './social_provider.ts'
|
|
62
|
+
export type { ProviderConfig, SocialConfig } from './types.ts'
|