@strav/social 0.4.31 → 1.0.0-alpha.22

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/index.ts +2 -0
  3. package/src/drivers/mock_driver.ts +170 -0
  4. package/src/drivers/unsupported.ts +17 -0
  5. package/src/dto/index.ts +4 -0
  6. package/src/dto/oauth_tokens.ts +26 -0
  7. package/src/dto/social_profile.ts +37 -0
  8. package/src/facebook/facebook_config.ts +68 -0
  9. package/src/facebook/facebook_driver.ts +321 -0
  10. package/src/facebook/facebook_provider.ts +29 -0
  11. package/src/facebook/index.ts +12 -0
  12. package/src/google/google_config.ts +44 -0
  13. package/src/google/google_driver.ts +317 -0
  14. package/src/google/google_provider.ts +33 -0
  15. package/src/google/index.ts +12 -0
  16. package/src/index.ts +61 -14
  17. package/src/ledger/apply_social_account_migration.ts +66 -0
  18. package/src/ledger/index.ts +12 -0
  19. package/src/ledger/social_account.ts +32 -0
  20. package/src/ledger/social_account_repository.ts +216 -0
  21. package/src/ledger/social_account_schema.ts +75 -0
  22. package/src/line/index.ts +12 -0
  23. package/src/line/line_config.ts +47 -0
  24. package/src/line/line_driver.ts +310 -0
  25. package/src/line/line_provider.ts +34 -0
  26. package/src/pkce.ts +63 -0
  27. package/src/social_capabilities.ts +35 -0
  28. package/src/social_driver.ts +105 -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 +149 -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,321 @@
1
+ /**
2
+ * `FacebookSocialDriver` — Facebook Login via the Graph API.
3
+ *
4
+ * Notable divergences from Line / Google:
5
+ *
6
+ * - **No OIDC.** Facebook does not issue `id_token`; the
7
+ * driver omits the `openid` capability. Apps that want a
8
+ * verifiable JWT use a different provider.
9
+ *
10
+ * - **No refresh tokens.** Facebook hands out short-lived
11
+ * (~1–2h) access tokens and a separate "long-lived token
12
+ * exchange" path that swaps the access token itself for a
13
+ * ~60-day variant. That's not a refresh-token grant in the
14
+ * framework's sense, so the driver omits the `tokens.refresh`
15
+ * capability and throws `ProviderUnsupportedError` from
16
+ * `refresh()`. Use `exchangeForLongLivedToken()` directly for
17
+ * the Facebook-specific flow.
18
+ *
19
+ * - **Email needs App Review.** Apps that ship the `email`
20
+ * scope to non-developer users have to go through Meta's
21
+ * review. The capability flag is declared because the
22
+ * driver CAN return email when granted — but apps gate the
23
+ * scope picker / button visibility on their own deployment
24
+ * state.
25
+ *
26
+ * - **`emailVerified` not asserted.** Facebook does not
27
+ * surface verification state; apps that need verified email
28
+ * send their own confirmation.
29
+ *
30
+ * - **Revoke** issues `DELETE /me/permissions`, which clears
31
+ * ALL granted scopes for the user. There's no per-scope
32
+ * revoke through the framework; apps that need it call the
33
+ * Graph API directly.
34
+ */
35
+
36
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
37
+ import type { SocialCapability } from '../social_capabilities.ts'
38
+ import type {
39
+ AuthorizeInput,
40
+ AuthorizeResult,
41
+ ExchangeInput,
42
+ RefreshInput,
43
+ SocialDriver,
44
+ } from '../social_driver.ts'
45
+ import {
46
+ InvalidTokenError,
47
+ OAuthExchangeError,
48
+ ProviderUnsupportedError,
49
+ SocialProviderError,
50
+ StateMismatchError,
51
+ } from '../social_error.ts'
52
+ import { codeChallengeFor, randomCodeVerifier, randomState } from '../pkce.ts'
53
+ import {
54
+ DEFAULT_FACEBOOK_PROFILE_FIELDS,
55
+ facebookEndpoints,
56
+ type FacebookProviderConfig,
57
+ } from './facebook_config.ts'
58
+
59
+ const PROVIDER = 'facebook'
60
+
61
+ const CAPS: readonly SocialCapability[] = [
62
+ // No `openid` — Facebook is plain OAuth2, no id_token.
63
+ 'pkce.support',
64
+ 'profile.id', 'profile.email', 'profile.name', 'profile.avatar', 'profile.locale',
65
+ // No `profile.emailVerified` — Facebook doesn't assert verification.
66
+ 'tokens.exchange',
67
+ // No `tokens.refresh` — see `exchangeForLongLivedToken` instead.
68
+ 'tokens.revoke', 'tokens.introspect',
69
+ 'scopes.discoverable',
70
+ ]
71
+
72
+ const SCOPES: readonly string[] = ['public_profile', 'email']
73
+
74
+ export interface FacebookDriverOptions {
75
+ instanceName: string
76
+ config: FacebookProviderConfig
77
+ }
78
+
79
+ interface TokenResponse {
80
+ access_token: string
81
+ expires_in?: number
82
+ token_type: string
83
+ scope?: string
84
+ }
85
+
86
+ interface PicturePayload {
87
+ data?: { url?: string; is_silhouette?: boolean }
88
+ }
89
+
90
+ interface MeResponse {
91
+ id: string
92
+ name?: string
93
+ email?: string
94
+ first_name?: string
95
+ last_name?: string
96
+ picture?: PicturePayload
97
+ locale?: string
98
+ }
99
+
100
+ interface DebugTokenResponse {
101
+ data?: {
102
+ user_id?: string
103
+ app_id?: string
104
+ is_valid?: boolean
105
+ expires_at?: number
106
+ scopes?: string[]
107
+ }
108
+ }
109
+
110
+ export class FacebookSocialDriver implements SocialDriver {
111
+ readonly name = PROVIDER
112
+ readonly instanceName: string
113
+ readonly capabilities: ReadonlySet<SocialCapability> = new Set(CAPS)
114
+ readonly availableScopes = SCOPES
115
+
116
+ private readonly config: FacebookProviderConfig
117
+ private readonly fetchFn: typeof fetch
118
+ private readonly endpoints: ReturnType<typeof facebookEndpoints>
119
+ private readonly profileFields: readonly string[]
120
+
121
+ constructor(options: FacebookDriverOptions) {
122
+ this.instanceName = options.instanceName
123
+ this.config = options.config
124
+ this.fetchFn = options.config.fetch ?? fetch
125
+ this.endpoints = {
126
+ ...facebookEndpoints(options.config.graphVersion),
127
+ ...(options.config.endpoints ?? {}),
128
+ }
129
+ this.profileFields = options.config.profileFields ?? DEFAULT_FACEBOOK_PROFILE_FIELDS
130
+ }
131
+
132
+ async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
133
+ const state = input.state ?? randomState()
134
+ const optOut = input.extra?.no_pkce === '1'
135
+ const codeVerifier = optOut
136
+ ? undefined
137
+ : input.codeVerifier ?? randomCodeVerifier()
138
+ const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
139
+
140
+ const scopes = input.scopes ?? ['public_profile']
141
+ const params = new URLSearchParams({
142
+ response_type: 'code',
143
+ client_id: this.config.clientId,
144
+ redirect_uri: input.redirectUri,
145
+ scope: scopes.join(','), // Facebook accepts comma OR space; comma is canonical.
146
+ state,
147
+ ...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
148
+ ...(input.extra ?? {}),
149
+ })
150
+ params.delete('no_pkce')
151
+
152
+ return {
153
+ url: `${this.endpoints.authorize}?${params.toString()}`,
154
+ state,
155
+ ...(codeVerifier ? { codeVerifier } : {}),
156
+ }
157
+ }
158
+
159
+ async exchange(input: ExchangeInput): Promise<OAuthTokens> {
160
+ if (input.expectedState !== undefined && input.state !== input.expectedState) {
161
+ throw new StateMismatchError()
162
+ }
163
+ // Facebook accepts GET with query string OR POST with form;
164
+ // we use POST form for symmetry with Line/Google.
165
+ const body = new URLSearchParams({
166
+ client_id: this.config.clientId,
167
+ client_secret: this.config.clientSecret,
168
+ redirect_uri: input.redirectUri,
169
+ code: input.code,
170
+ ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
171
+ })
172
+ const res = await this.fetchFn(this.endpoints.token, {
173
+ method: 'POST',
174
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
175
+ body,
176
+ })
177
+ if (!res.ok) {
178
+ const text = await res.text()
179
+ throw new OAuthExchangeError(
180
+ `FacebookSocialDriver.exchange: token endpoint returned ${res.status}.`,
181
+ { context: { status: res.status, body: text } },
182
+ )
183
+ }
184
+ return this.toOAuthTokens((await res.json()) as TokenResponse)
185
+ }
186
+
187
+ async profile(accessToken: string): Promise<SocialProfile> {
188
+ const url = `${this.endpoints.me}?${new URLSearchParams({
189
+ fields: this.profileFields.join(','),
190
+ access_token: accessToken,
191
+ }).toString()}`
192
+ const res = await this.fetchFn(url)
193
+ if (res.status === 401 || res.status === 400) {
194
+ // Facebook returns 400 for revoked / expired tokens (not 401).
195
+ throw new InvalidTokenError('FacebookSocialDriver.profile: access token rejected.')
196
+ }
197
+ if (!res.ok) {
198
+ const text = await res.text()
199
+ throw new SocialProviderError(
200
+ `FacebookSocialDriver.profile: Graph API returned ${res.status}.`,
201
+ { provider: PROVIDER, operation: 'profile', context: { status: res.status, body: text } },
202
+ )
203
+ }
204
+ const me = (await res.json()) as MeResponse
205
+ return {
206
+ id: me.id,
207
+ provider: PROVIDER,
208
+ ...(me.email ? { email: me.email } : {}),
209
+ ...(me.name ? { name: me.name } : {}),
210
+ ...(me.picture?.data?.url ? { avatarUrl: me.picture.data.url } : {}),
211
+ ...(me.locale ? { locale: me.locale } : {}),
212
+ metadata: {
213
+ ...(me.first_name ? { firstName: me.first_name } : {}),
214
+ ...(me.last_name ? { lastName: me.last_name } : {}),
215
+ ...(me.picture?.data?.is_silhouette
216
+ ? { isSilhouette: me.picture.data.is_silhouette }
217
+ : {}),
218
+ },
219
+ raw: me,
220
+ }
221
+ }
222
+
223
+ refresh(_input: RefreshInput): Promise<OAuthTokens> {
224
+ throw new ProviderUnsupportedError(PROVIDER, 'tokens.refresh', {
225
+ reason:
226
+ 'Facebook does not issue refresh tokens. Use `exchangeForLongLivedToken(accessToken, driver)` to swap a short-lived access token for the ~60-day variant — note this trades the access token itself, not a separate refresh token.',
227
+ })
228
+ }
229
+
230
+ async revoke(token: string): Promise<void> {
231
+ // DELETE /me/permissions clears every scope this user
232
+ // granted to the app. Per-scope revoke isn't bridged; apps
233
+ // that need it call the Graph API directly.
234
+ const url = `${this.endpoints.permissions}?${new URLSearchParams({
235
+ access_token: token,
236
+ }).toString()}`
237
+ const res = await this.fetchFn(url, { method: 'DELETE' })
238
+ if (!res.ok) {
239
+ const text = await res.text()
240
+ throw new SocialProviderError(
241
+ `FacebookSocialDriver.revoke: Graph API returned ${res.status}.`,
242
+ { provider: PROVIDER, operation: 'revoke', context: { status: res.status, body: text } },
243
+ )
244
+ }
245
+ }
246
+
247
+ // ─── Facebook-specific helpers (advanced) ───────────────────────────
248
+
249
+ /**
250
+ * Trade a short-lived access token (~1–2h) for a long-lived
251
+ * one (~60 days). Facebook calls this `fb_exchange_token`; the
252
+ * framework's `refresh()` throws because it doesn't fit the
253
+ * refresh-token contract. Apps that hold tokens across sessions
254
+ * call this once after the initial exchange.
255
+ */
256
+ async exchangeForLongLivedToken(accessToken: string): Promise<OAuthTokens> {
257
+ const body = new URLSearchParams({
258
+ grant_type: 'fb_exchange_token',
259
+ client_id: this.config.clientId,
260
+ client_secret: this.config.clientSecret,
261
+ fb_exchange_token: accessToken,
262
+ })
263
+ const res = await this.fetchFn(this.endpoints.token, {
264
+ method: 'POST',
265
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
266
+ body,
267
+ })
268
+ if (res.status === 400 || res.status === 401) {
269
+ throw new InvalidTokenError(
270
+ `FacebookSocialDriver.exchangeForLongLivedToken: short-lived token rejected.`,
271
+ { context: { status: res.status } },
272
+ )
273
+ }
274
+ if (!res.ok) {
275
+ throw new SocialProviderError(
276
+ `FacebookSocialDriver.exchangeForLongLivedToken: Graph API returned ${res.status}.`,
277
+ { provider: PROVIDER, operation: 'long_lived_exchange', context: { status: res.status } },
278
+ )
279
+ }
280
+ return this.toOAuthTokens((await res.json()) as TokenResponse)
281
+ }
282
+
283
+ /**
284
+ * Inspect a token via the Graph API's `debug_token` endpoint.
285
+ * Returns the raw payload — apps read `is_valid`, `expires_at`,
286
+ * `scopes`, etc. Implementation note: the `input_token` is what
287
+ * we're checking; the `access_token` is the *app* token used to
288
+ * authenticate the call (`client_id|client_secret`).
289
+ */
290
+ async debugToken(token: string): Promise<DebugTokenResponse> {
291
+ const appToken = `${this.config.clientId}|${this.config.clientSecret}`
292
+ const url = `${this.endpoints.debugToken}?${new URLSearchParams({
293
+ input_token: token,
294
+ access_token: appToken,
295
+ }).toString()}`
296
+ const res = await this.fetchFn(url)
297
+ if (!res.ok) {
298
+ throw new SocialProviderError(
299
+ `FacebookSocialDriver.debugToken: Graph API returned ${res.status}.`,
300
+ { provider: PROVIDER, operation: 'introspect', context: { status: res.status } },
301
+ )
302
+ }
303
+ return (await res.json()) as DebugTokenResponse
304
+ }
305
+
306
+ // ─── Internals ────────────────────────────────────────────────────────
307
+
308
+ private toOAuthTokens(t: TokenResponse): OAuthTokens {
309
+ const expiresAt =
310
+ typeof t.expires_in === 'number'
311
+ ? new Date(Date.now() + t.expires_in * 1000)
312
+ : undefined
313
+ return {
314
+ accessToken: t.access_token,
315
+ ...(expiresAt ? { expiresAt } : {}),
316
+ ...(t.scope ? { scope: t.scope } : {}),
317
+ tokenType: t.token_type ?? 'Bearer',
318
+ raw: t,
319
+ }
320
+ }
321
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `FacebookSocialProvider` — `ServiceProvider` that registers
3
+ * the Facebook driver factory on the `SocialManager`.
4
+ */
5
+
6
+ import { type Application, ServiceProvider } from '@strav/kernel'
7
+ import { SocialConfigError } from '../social_error.ts'
8
+ import { SocialManager } from '../social_manager.ts'
9
+ import type { FacebookProviderConfig } from './facebook_config.ts'
10
+ import { FacebookSocialDriver } from './facebook_driver.ts'
11
+
12
+ export class FacebookSocialProvider extends ServiceProvider {
13
+ override readonly name = 'social-facebook'
14
+ override readonly dependencies = ['social']
15
+
16
+ override register(app: Application): void {
17
+ const manager = app.resolve(SocialManager)
18
+ manager.extend('facebook', ({ instanceName, config }) => {
19
+ const cfg = config as FacebookProviderConfig
20
+ if (!cfg.clientId || !cfg.clientSecret) {
21
+ throw new SocialConfigError(
22
+ `FacebookSocialProvider: \`clientId\` and \`clientSecret\` are required for provider "${instanceName}".`,
23
+ { context: { instanceName } },
24
+ )
25
+ }
26
+ return new FacebookSocialDriver({ instanceName, config: cfg })
27
+ })
28
+ }
29
+ }
@@ -0,0 +1,12 @@
1
+ // Public API of `@strav/social/facebook`.
2
+
3
+ export {
4
+ DEFAULT_FACEBOOK_PROFILE_FIELDS,
5
+ facebookEndpoints,
6
+ type FacebookProviderConfig,
7
+ } from './facebook_config.ts'
8
+ export {
9
+ FacebookSocialDriver,
10
+ type FacebookDriverOptions,
11
+ } from './facebook_driver.ts'
12
+ export { FacebookSocialProvider } from './facebook_provider.ts'
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Google-specific provider config. Apps put one of these inside
3
+ * `config.social.providers[name]` with `driver: 'google'`.
4
+ *
5
+ * Get credentials from https://console.cloud.google.com → APIs &
6
+ * Services → Credentials → "OAuth 2.0 Client IDs". For server-
7
+ * side apps choose "Web application"; for mobile / SPA see the
8
+ * dedicated client types (PKCE is mandatory there).
9
+ *
10
+ * The Google Workspace `hd` (hosted-domain) constraint can be
11
+ * enforced per-authorize via `authorize({ extra: { hd: 'example.com' } })`.
12
+ */
13
+
14
+ import type { ProviderConfig } from '../types.ts'
15
+
16
+ export interface GoogleProviderConfig extends ProviderConfig {
17
+ driver: 'google'
18
+ clientId: string
19
+ clientSecret: string
20
+ /**
21
+ * Default to requesting refresh tokens. Default `true`. When
22
+ * `false`, the authorize URL omits `access_type=offline` and
23
+ * `refresh()` will fail later — only useful for short-lived
24
+ * "sign in once" flows where the app never holds long-term tokens.
25
+ */
26
+ offlineAccess?: boolean
27
+ /** Override endpoints for testing — never set in production. */
28
+ endpoints?: {
29
+ authorize?: string
30
+ token?: string
31
+ userInfo?: string
32
+ revoke?: string
33
+ tokenInfo?: string
34
+ }
35
+ fetch?: typeof fetch
36
+ }
37
+
38
+ export const GOOGLE_ENDPOINTS = {
39
+ authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
40
+ token: 'https://oauth2.googleapis.com/token',
41
+ userInfo: 'https://openidconnect.googleapis.com/v1/userinfo',
42
+ revoke: 'https://oauth2.googleapis.com/revoke',
43
+ tokenInfo: 'https://oauth2.googleapis.com/tokeninfo',
44
+ } as const