@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.
Files changed (52) hide show
  1. package/package.json +23 -16
  2. package/src/drivers/facebook/facebook_config.ts +68 -0
  3. package/src/drivers/facebook/facebook_driver.ts +321 -0
  4. package/src/drivers/facebook/facebook_provider.ts +29 -0
  5. package/src/drivers/facebook/index.ts +12 -0
  6. package/src/drivers/google/google_config.ts +44 -0
  7. package/src/drivers/google/google_driver.ts +317 -0
  8. package/src/drivers/google/google_provider.ts +33 -0
  9. package/src/drivers/google/index.ts +12 -0
  10. package/src/drivers/index.ts +2 -0
  11. package/src/drivers/line/index.ts +12 -0
  12. package/src/drivers/line/line_config.ts +47 -0
  13. package/src/drivers/line/line_driver.ts +310 -0
  14. package/src/drivers/line/line_provider.ts +34 -0
  15. package/src/drivers/mock_driver.ts +170 -0
  16. package/src/drivers/unsupported.ts +17 -0
  17. package/src/dto/index.ts +4 -0
  18. package/src/dto/oauth_tokens.ts +26 -0
  19. package/src/dto/social_profile.ts +37 -0
  20. package/src/index.ts +61 -14
  21. package/src/ledger/apply_social_account_migration.ts +66 -0
  22. package/src/ledger/index.ts +12 -0
  23. package/src/ledger/social_account.ts +32 -0
  24. package/src/ledger/social_account_repository.ts +203 -0
  25. package/src/ledger/social_account_schema.ts +75 -0
  26. package/src/pkce.ts +63 -0
  27. package/src/social_capabilities.ts +35 -0
  28. package/src/social_driver.ts +143 -0
  29. package/src/social_error.ts +155 -0
  30. package/src/social_manager.ts +92 -74
  31. package/src/social_provider.ts +41 -7
  32. package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
  33. package/src/tenanted/index.ts +18 -0
  34. package/src/tenanted/tenanted_social_account.ts +30 -0
  35. package/src/tenanted/tenanted_social_account_repository.ts +136 -0
  36. package/src/tenanted/tenanted_social_account_schema.ts +44 -0
  37. package/src/types.ts +15 -43
  38. package/CHANGELOG.md +0 -19
  39. package/README.md +0 -78
  40. package/src/abstract_provider.ts +0 -182
  41. package/src/helpers.ts +0 -31
  42. package/src/providers/discord_provider.ts +0 -59
  43. package/src/providers/facebook_provider.ts +0 -69
  44. package/src/providers/github_provider.ts +0 -75
  45. package/src/providers/google_provider.ts +0 -51
  46. package/src/providers/line_provider.ts +0 -73
  47. package/src/providers/linkedin_provider.ts +0 -51
  48. package/src/schema.ts +0 -13
  49. package/src/social_account.ts +0 -238
  50. package/stubs/config/social.ts +0 -22
  51. package/stubs/schemas/social_account.ts +0 -13
  52. 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
+ }
@@ -0,0 +1,4 @@
1
+ /** Barrel for the social DTOs. */
2
+
3
+ export type { OAuthTokens } from './oauth_tokens.ts'
4
+ export type { SocialProfile } from './social_profile.ts'
@@ -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
- export { default, default as SocialManager } from './social_manager.ts'
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
- // Provider
4
- export { default as SocialProvider } from './social_provider.ts'
5
- export { default as SocialAccount } from './social_account.ts'
6
- export { social } from './helpers.ts'
7
- export { AbstractProvider, SocialError } from './abstract_provider.ts'
8
- export { GoogleProvider } from './providers/google_provider.ts'
9
- export { GitHubProvider } from './providers/github_provider.ts'
10
- export { DiscordProvider } from './providers/discord_provider.ts'
11
- export { FacebookProvider } from './providers/facebook_provider.ts'
12
- export { LinkedInProvider } from './providers/linkedin_provider.ts'
13
- export { LineProvider } from './providers/line_provider.ts'
14
- export type { SocialUser, SocialConfig, ProviderConfig, TokenResponse } from './types.ts'
15
- export type { SocialAccountData } from './social_account.ts'
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'