@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,317 @@
1
+ /**
2
+ * `GoogleSocialDriver` — Google Sign-In (OAuth 2.0 + OIDC) via
3
+ * the standard Web application client type.
4
+ *
5
+ * Notes worth knowing:
6
+ *
7
+ * - **Scopes**: `openid` (id_token), `profile`, `email`.
8
+ * Google accepts both short (`'email'`) and fully-qualified
9
+ * (`'https://www.googleapis.com/auth/userinfo.email'`) forms;
10
+ * we use the short forms.
11
+ *
12
+ * - **PKCE**: supported. Mandatory for `installed` / SPA
13
+ * client types; optional for `Web application`. The driver
14
+ * defaults PKCE on as defence in depth (matches Line + the
15
+ * OAuth 2.1 trajectory).
16
+ *
17
+ * - **Refresh tokens**: Google only issues `refresh_token` on
18
+ * `access_type=offline`. The driver defaults to including
19
+ * it. A `refresh_token` is returned ONLY on the user's
20
+ * first consent unless `prompt=consent` forces re-consent.
21
+ * Apps re-establishing offline access after revocation pass
22
+ * `extra: { prompt: 'consent' }`.
23
+ *
24
+ * - **id_token**: returned when `openid` is in the requested
25
+ * scope set. Decoding helper provided for `email` extraction,
26
+ * mirroring `emailFromLineIdToken`. Signature verification
27
+ * deferred to apps (use Google's `tokeninfo` endpoint or a
28
+ * JWT library + Google's JWKS).
29
+ *
30
+ * - **`hd` (hosted domain)**: Google Workspace constraint —
31
+ * pass `extra: { hd: 'example.com' }` on authorize to limit
32
+ * consent to one Workspace domain.
33
+ */
34
+
35
+ import type { OAuthTokens, SocialProfile } from '../../dto/index.ts'
36
+ import type { SocialCapability } from '../../social_capabilities.ts'
37
+ import type {
38
+ AuthorizeInput,
39
+ AuthorizeResult,
40
+ ExchangeInput,
41
+ RefreshInput,
42
+ SocialDriver,
43
+ } from '../../social_driver.ts'
44
+ import {
45
+ InvalidTokenError,
46
+ OAuthExchangeError,
47
+ SocialProviderError,
48
+ StateMismatchError,
49
+ } from '../../social_error.ts'
50
+ import { codeChallengeFor, randomCodeVerifier, randomState } from '../../pkce.ts'
51
+ import { GOOGLE_ENDPOINTS, type GoogleProviderConfig } from './google_config.ts'
52
+
53
+ const PROVIDER = 'google'
54
+
55
+ const CAPS: readonly SocialCapability[] = [
56
+ 'openid', 'pkce.support',
57
+ 'profile.id', 'profile.email', 'profile.emailVerified',
58
+ 'profile.name', 'profile.avatar', 'profile.locale',
59
+ 'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
60
+ 'scopes.discoverable',
61
+ ]
62
+
63
+ const SCOPES: readonly string[] = ['openid', 'profile', 'email']
64
+
65
+ export interface GoogleDriverOptions {
66
+ instanceName: string
67
+ config: GoogleProviderConfig
68
+ }
69
+
70
+ interface TokenResponse {
71
+ access_token: string
72
+ expires_in: number
73
+ id_token?: string
74
+ refresh_token?: string
75
+ scope?: string
76
+ token_type: string
77
+ }
78
+
79
+ interface UserInfoResponse {
80
+ sub: string
81
+ email?: string
82
+ email_verified?: boolean
83
+ name?: string
84
+ given_name?: string
85
+ family_name?: string
86
+ picture?: string
87
+ locale?: string
88
+ }
89
+
90
+ interface JwtPayload {
91
+ email?: string
92
+ email_verified?: boolean
93
+ sub?: string
94
+ [k: string]: unknown
95
+ }
96
+
97
+ export class GoogleSocialDriver implements SocialDriver {
98
+ readonly name = PROVIDER
99
+ readonly instanceName: string
100
+ readonly capabilities: ReadonlySet<SocialCapability> = new Set(CAPS)
101
+ readonly availableScopes = SCOPES
102
+
103
+ private readonly config: GoogleProviderConfig
104
+ private readonly fetchFn: typeof fetch
105
+ private readonly endpoints: {
106
+ authorize: string
107
+ token: string
108
+ userInfo: string
109
+ revoke: string
110
+ tokenInfo: string
111
+ }
112
+
113
+ constructor(options: GoogleDriverOptions) {
114
+ this.instanceName = options.instanceName
115
+ this.config = options.config
116
+ this.fetchFn = options.config.fetch ?? fetch
117
+ this.endpoints = { ...GOOGLE_ENDPOINTS, ...(options.config.endpoints ?? {}) }
118
+ }
119
+
120
+ async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
121
+ const state = input.state ?? randomState()
122
+ const optOut = input.extra?.no_pkce === '1'
123
+ const codeVerifier = optOut
124
+ ? undefined
125
+ : input.codeVerifier ?? randomCodeVerifier()
126
+ const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
127
+
128
+ const scopes = input.scopes ?? ['openid', 'profile', 'email']
129
+ const offline = this.config.offlineAccess !== false
130
+
131
+ const params = new URLSearchParams({
132
+ response_type: 'code',
133
+ client_id: this.config.clientId,
134
+ redirect_uri: input.redirectUri,
135
+ scope: scopes.join(' '),
136
+ state,
137
+ ...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
138
+ ...(offline ? { access_type: 'offline' } : {}),
139
+ // `include_granted_scopes=true` makes Google merge previous
140
+ // grants rather than replace — apps that progressively
141
+ // request scopes appreciate it; safe-by-default.
142
+ include_granted_scopes: 'true',
143
+ ...(input.extra ?? {}),
144
+ })
145
+ params.delete('no_pkce')
146
+
147
+ return {
148
+ url: `${this.endpoints.authorize}?${params.toString()}`,
149
+ state,
150
+ ...(codeVerifier ? { codeVerifier } : {}),
151
+ }
152
+ }
153
+
154
+ async exchange(input: ExchangeInput): Promise<OAuthTokens> {
155
+ if (input.expectedState !== undefined && input.state !== input.expectedState) {
156
+ throw new StateMismatchError()
157
+ }
158
+ const body = new URLSearchParams({
159
+ grant_type: 'authorization_code',
160
+ code: input.code,
161
+ redirect_uri: input.redirectUri,
162
+ client_id: this.config.clientId,
163
+ client_secret: this.config.clientSecret,
164
+ ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
165
+ })
166
+ const res = await this.fetchFn(this.endpoints.token, {
167
+ method: 'POST',
168
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
169
+ body,
170
+ })
171
+ if (!res.ok) {
172
+ const text = await res.text()
173
+ throw new OAuthExchangeError(
174
+ `GoogleSocialDriver.exchange: token endpoint returned ${res.status}.`,
175
+ { context: { status: res.status, body: text } },
176
+ )
177
+ }
178
+ return this.toOAuthTokens((await res.json()) as TokenResponse)
179
+ }
180
+
181
+ async profile(accessToken: string): Promise<SocialProfile> {
182
+ const res = await this.fetchFn(this.endpoints.userInfo, {
183
+ headers: { authorization: `Bearer ${accessToken}` },
184
+ })
185
+ if (res.status === 401) {
186
+ throw new InvalidTokenError('GoogleSocialDriver.profile: access token rejected.')
187
+ }
188
+ if (!res.ok) {
189
+ const text = await res.text()
190
+ throw new SocialProviderError(
191
+ `GoogleSocialDriver.profile: userinfo endpoint returned ${res.status}.`,
192
+ { provider: PROVIDER, operation: 'profile', context: { status: res.status, body: text } },
193
+ )
194
+ }
195
+ const u = (await res.json()) as UserInfoResponse
196
+ return {
197
+ id: u.sub,
198
+ provider: PROVIDER,
199
+ ...(u.email ? { email: u.email } : {}),
200
+ ...(u.email_verified !== undefined ? { emailVerified: u.email_verified } : {}),
201
+ ...(u.name ? { name: u.name } : {}),
202
+ ...(u.picture ? { avatarUrl: u.picture } : {}),
203
+ ...(u.locale ? { locale: u.locale } : {}),
204
+ metadata: {
205
+ ...(u.given_name ? { givenName: u.given_name } : {}),
206
+ ...(u.family_name ? { familyName: u.family_name } : {}),
207
+ },
208
+ raw: u,
209
+ }
210
+ }
211
+
212
+ async refresh(input: RefreshInput): Promise<OAuthTokens> {
213
+ const body = new URLSearchParams({
214
+ grant_type: 'refresh_token',
215
+ refresh_token: input.refreshToken,
216
+ client_id: this.config.clientId,
217
+ client_secret: this.config.clientSecret,
218
+ })
219
+ const res = await this.fetchFn(this.endpoints.token, {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
222
+ body,
223
+ })
224
+ if (res.status === 400 || res.status === 401) {
225
+ const text = await res.text()
226
+ throw new InvalidTokenError(
227
+ `GoogleSocialDriver.refresh: refresh token rejected.`,
228
+ { context: { status: res.status, body: text } },
229
+ )
230
+ }
231
+ if (!res.ok) {
232
+ const text = await res.text()
233
+ throw new SocialProviderError(
234
+ `GoogleSocialDriver.refresh: token endpoint returned ${res.status}.`,
235
+ { provider: PROVIDER, operation: 'refresh', context: { status: res.status, body: text } },
236
+ )
237
+ }
238
+ // Google does NOT rotate refresh tokens; preserve the caller's
239
+ // current refresh token if the response omits it.
240
+ const json = (await res.json()) as TokenResponse
241
+ const tokens = this.toOAuthTokens(json)
242
+ if (!tokens.refreshToken) tokens.refreshToken = input.refreshToken
243
+ return tokens
244
+ }
245
+
246
+ async revoke(token: string): Promise<void> {
247
+ // Google's revoke endpoint takes the token as a query
248
+ // parameter OR a form body; we POST form for consistency
249
+ // with the rest of the driver.
250
+ const body = new URLSearchParams({ token })
251
+ const res = await this.fetchFn(this.endpoints.revoke, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
254
+ body,
255
+ })
256
+ if (!res.ok) {
257
+ const text = await res.text()
258
+ throw new SocialProviderError(
259
+ `GoogleSocialDriver.revoke: revoke endpoint returned ${res.status}.`,
260
+ { provider: PROVIDER, operation: 'revoke', context: { status: res.status, body: text } },
261
+ )
262
+ }
263
+ }
264
+
265
+ // ─── Internals ────────────────────────────────────────────────────────
266
+
267
+ private toOAuthTokens(t: TokenResponse): OAuthTokens {
268
+ const expiresAt =
269
+ typeof t.expires_in === 'number'
270
+ ? new Date(Date.now() + t.expires_in * 1000)
271
+ : undefined
272
+ return {
273
+ accessToken: t.access_token,
274
+ ...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
275
+ ...(t.id_token ? { idToken: t.id_token } : {}),
276
+ ...(expiresAt ? { expiresAt } : {}),
277
+ ...(t.scope ? { scope: t.scope } : {}),
278
+ tokenType: t.token_type ?? 'Bearer',
279
+ raw: t,
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Extract the `email` claim from a Google id_token (JWT). Same
286
+ * semantics as `emailFromLineIdToken` — decode-only, no JWS
287
+ * signature verification. Apps with stricter posture verify via
288
+ * Google's `tokeninfo?id_token=...` endpoint.
289
+ *
290
+ * Google's userinfo endpoint also returns `email` + `email_verified`,
291
+ * so most apps don't need this helper — it's here for paths that
292
+ * skip userinfo (e.g. server-only signin where the access token
293
+ * is discarded immediately after exchange).
294
+ */
295
+ export function emailFromGoogleIdToken(idToken: string): string | null {
296
+ const segments = idToken.split('.')
297
+ if (segments.length !== 3) {
298
+ throw new InvalidTokenError(
299
+ 'emailFromGoogleIdToken: id_token does not have 3 segments.',
300
+ )
301
+ }
302
+ const payload = decodeJwtSegment(segments[1]!)
303
+ return typeof payload.email === 'string' ? payload.email : null
304
+ }
305
+
306
+ function decodeJwtSegment(segment: string): JwtPayload {
307
+ const pad = segment.length % 4
308
+ const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
309
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
310
+ try {
311
+ return JSON.parse(atob(b64)) as JwtPayload
312
+ } catch (cause) {
313
+ throw new InvalidTokenError('decodeJwtSegment: failed to parse JWT segment.', {
314
+ cause,
315
+ })
316
+ }
317
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `GoogleSocialProvider` — `ServiceProvider` that registers the
3
+ * Google driver factory on the `SocialManager`.
4
+ *
5
+ * Listed AFTER `SocialProvider` in `bootstrap/providers.ts`. The
6
+ * factory is invoked lazily on first `social.use(name)`;
7
+ * misconfigured credentials surface on first use.
8
+ */
9
+
10
+ import { type Application, ServiceProvider } from '@strav/kernel'
11
+ import { SocialConfigError } from '../../social_error.ts'
12
+ import { SocialManager } from '../../social_manager.ts'
13
+ import type { GoogleProviderConfig } from './google_config.ts'
14
+ import { GoogleSocialDriver } from './google_driver.ts'
15
+
16
+ export class GoogleSocialProvider extends ServiceProvider {
17
+ override readonly name = 'social-google'
18
+ override readonly dependencies = ['social']
19
+
20
+ override register(app: Application): void {
21
+ const manager = app.resolve(SocialManager)
22
+ manager.extend('google', ({ instanceName, config }) => {
23
+ const cfg = config as GoogleProviderConfig
24
+ if (!cfg.clientId || !cfg.clientSecret) {
25
+ throw new SocialConfigError(
26
+ `GoogleSocialProvider: \`clientId\` and \`clientSecret\` are required for provider "${instanceName}".`,
27
+ { context: { instanceName } },
28
+ )
29
+ }
30
+ return new GoogleSocialDriver({ instanceName, config: cfg })
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,12 @@
1
+ // Public API of `@strav/social/google`.
2
+
3
+ export {
4
+ GOOGLE_ENDPOINTS,
5
+ type GoogleProviderConfig,
6
+ } from './google_config.ts'
7
+ export {
8
+ emailFromGoogleIdToken,
9
+ GoogleSocialDriver,
10
+ type GoogleDriverOptions,
11
+ } from './google_driver.ts'
12
+ export { GoogleSocialProvider } from './google_provider.ts'
@@ -0,0 +1,2 @@
1
+ export { MockDriver, type MockDriverOptions } from './mock_driver.ts'
2
+ export { unsupported } from './unsupported.ts'
@@ -0,0 +1,12 @@
1
+ // Public API of `@strav/social/line`.
2
+
3
+ export {
4
+ LINE_ENDPOINTS,
5
+ type LineProviderConfig,
6
+ } from './line_config.ts'
7
+ export {
8
+ emailFromLineIdToken,
9
+ LineSocialDriver,
10
+ type LineDriverOptions,
11
+ } from './line_driver.ts'
12
+ export { LineSocialProvider } from './line_provider.ts'
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Line-specific provider config. Apps put one of these inside
3
+ * `config.social.providers[name]` with `driver: 'line'`.
4
+ *
5
+ * Get credentials from https://developers.line.biz/console — a
6
+ * Line Login channel under a provider. The `email` scope
7
+ * additionally needs the "email permission" toggle to be enabled
8
+ * inside the channel (Line approval required for production).
9
+ */
10
+
11
+ import type { ProviderConfig } from '../../types.ts'
12
+
13
+ export interface LineProviderConfig extends ProviderConfig {
14
+ driver: 'line'
15
+ /** Channel ID from the Line Developers console. */
16
+ clientId: string
17
+ /** Channel secret from the Line Developers console. */
18
+ clientSecret: string
19
+ /**
20
+ * Optional UI locale hint passed on every authorize URL —
21
+ * `'th-TH'`, `'ja-JP'`, `'en-US'`, … Apps that route by user
22
+ * locale override per-call via `authorize({ extra: { ui_locales } })`.
23
+ * Defaults to Line's autodetect.
24
+ */
25
+ uiLocales?: string
26
+ /** Override endpoints for testing — never set in production. */
27
+ endpoints?: {
28
+ authorize?: string
29
+ token?: string
30
+ profile?: string
31
+ revoke?: string
32
+ verify?: string
33
+ }
34
+ /**
35
+ * Custom `fetch` override (tests). Defaults to global `fetch`.
36
+ * The driver does no other I/O.
37
+ */
38
+ fetch?: typeof fetch
39
+ }
40
+
41
+ export const LINE_ENDPOINTS = {
42
+ authorize: 'https://access.line.me/oauth2/v2.1/authorize',
43
+ token: 'https://api.line.me/oauth2/v2.1/token',
44
+ profile: 'https://api.line.me/v2/profile',
45
+ revoke: 'https://api.line.me/oauth2/v2.1/revoke',
46
+ verify: 'https://api.line.me/oauth2/v2.1/verify',
47
+ } as const