@strav/social 0.4.30 → 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,216 @@
1
+ /**
2
+ * `SocialAccountRepository` — domain helpers on top of the
3
+ * generic CRUD surface. The four methods apps actually use:
4
+ *
5
+ * - `connect({ userId, provider, profile, tokens })` — upsert
6
+ * by `(provider, provider_user_id)` within the tenant scope.
7
+ * Runs on every sign-in: a returning user's tokens get
8
+ * refreshed; a first-time link inserts.
9
+ *
10
+ * - `disconnect({ userId, provider })` — delete the link.
11
+ * Apps invoke this when the user unlinks a provider; for
12
+ * full token revocation, drivers' `revoke()` runs separately
13
+ * (this method doesn't reach the provider).
14
+ *
15
+ * - `findByUser(userId)` — list every linked provider for a
16
+ * user. Apps render account-settings UIs from this.
17
+ *
18
+ * - `findByProviderIdentity(provider, providerUserId)` — the
19
+ * sign-in lookup: "we just verified an OAuth identity, who
20
+ * does it belong to?" Returns the row including `user_id`
21
+ * so the caller can hydrate the app's User.
22
+ *
23
+ * Tokens are encrypted via the Model's `@encrypt` decorators —
24
+ * Repository hydration handles it transparently. Apps must have
25
+ * an `EncryptionProvider` registered; otherwise the first
26
+ * encrypt/decrypt throws `ConfigError`.
27
+ */
28
+
29
+ // biome-ignore lint/style/useImportType: PostgresDatabase + SchemaRegistry value imports for @inject() metadata.
30
+ import { PostgresDatabase, quoteIdent, Repository, SchemaRegistry } from '@strav/database'
31
+ // biome-ignore lint/style/useImportType: Cipher + EventBus value imports for @inject() metadata.
32
+ import { Cipher, EventBus, inject, ulid } from '@strav/kernel'
33
+ import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
34
+ import { SocialAccount } from './social_account.ts'
35
+ import { socialAccountSchema } from './social_account_schema.ts'
36
+
37
+ export interface ConnectInput {
38
+ /** App-side user id. */
39
+ userId: string
40
+ /** Provider-instance name (matches `social.use(name)`). Distinct from `profile.provider` when one driver is wired under multiple names. */
41
+ provider: string
42
+ profile: SocialProfile
43
+ tokens: OAuthTokens
44
+ }
45
+
46
+ export interface DisconnectInput {
47
+ userId: string
48
+ provider: string
49
+ }
50
+
51
+ @inject()
52
+ export class SocialAccountRepository extends Repository<SocialAccount> {
53
+ static override readonly schema = socialAccountSchema
54
+ static override readonly model = SocialAccount
55
+
56
+ // biome-ignore lint/complexity/noUselessConstructor: explicit constructor forces TS to emit `design:paramtypes` for @inject(). The fourth param is the Cipher for @encrypt token columns — apps must register EncryptionProvider before this repository resolves.
57
+ constructor(
58
+ db: PostgresDatabase,
59
+ events: EventBus,
60
+ registry?: SchemaRegistry,
61
+ cipher?: Cipher,
62
+ ) {
63
+ super(db, events, registry, cipher)
64
+ }
65
+
66
+ /**
67
+ * Upsert a social account by `(provider, provider_user_id)`.
68
+ * Insert on first link; update tokens + cached profile fields
69
+ * on subsequent sign-ins.
70
+ *
71
+ * No tenant scoping required — the default schema is
72
+ * non-tenanted. Apps that opted into the tenanted variant
73
+ * (`@strav/social/tenanted`) wrap calls in
74
+ * `TenantManager.withTenant(...)`; that variant ships its own
75
+ * Repository.
76
+ */
77
+ async connect(input: ConnectInput): Promise<SocialAccount> {
78
+ const existing = await this.findByProviderIdentity(
79
+ input.provider,
80
+ input.profile.id,
81
+ )
82
+ const now = new Date()
83
+
84
+ if (existing) {
85
+ // Cross-user link guard: if the existing row belongs to a
86
+ // different user, refuse — the app needs to resolve the
87
+ // conflict explicitly (typically "this Google account is
88
+ // already linked to another user").
89
+ if (existing.user_id !== input.userId) {
90
+ throw new SocialAccountAlreadyLinkedError({
91
+ provider: input.provider,
92
+ providerUserId: input.profile.id,
93
+ existingUserId: existing.user_id,
94
+ attemptedUserId: input.userId,
95
+ })
96
+ }
97
+ // Same user, returning sign-in: refresh tokens + cached
98
+ // profile fields via the standard Repository.update path
99
+ // (handles `@cast` / `@encrypt` round-trips for us).
100
+ return this.update(existing, {
101
+ email: input.profile.email ?? null,
102
+ name: input.profile.name ?? null,
103
+ avatar_url: input.profile.avatarUrl ?? null,
104
+ locale: input.profile.locale ?? null,
105
+ access_token: input.tokens.accessToken,
106
+ refresh_token: input.tokens.refreshToken ?? null,
107
+ id_token: input.tokens.idToken ?? null,
108
+ expires_at: input.tokens.expiresAt ?? null,
109
+ scope: input.tokens.scope ?? null,
110
+ updated_at: now,
111
+ } as Partial<SocialAccount>)
112
+ }
113
+
114
+ return this.create({
115
+ id: ulid(),
116
+ user_id: input.userId,
117
+ provider: input.provider,
118
+ provider_user_id: input.profile.id,
119
+ email: input.profile.email ?? null,
120
+ name: input.profile.name ?? null,
121
+ avatar_url: input.profile.avatarUrl ?? null,
122
+ locale: input.profile.locale ?? null,
123
+ access_token: input.tokens.accessToken,
124
+ refresh_token: input.tokens.refreshToken ?? null,
125
+ id_token: input.tokens.idToken ?? null,
126
+ expires_at: input.tokens.expiresAt ?? null,
127
+ scope: input.tokens.scope ?? null,
128
+ metadata: {},
129
+ created_at: now,
130
+ updated_at: now,
131
+ } as Partial<SocialAccount>)
132
+ }
133
+
134
+ /** Delete the link. No-op when nothing matches. */
135
+ async disconnect(input: DisconnectInput): Promise<void> {
136
+ const table = quoteIdent(socialAccountSchema.name)
137
+ await this.db.execute(
138
+ `DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
139
+ [input.userId, input.provider],
140
+ )
141
+ }
142
+
143
+ /** Every social account linked to one user. */
144
+ async findByUser(userId: string): Promise<SocialAccount[]> {
145
+ const table = quoteIdent(socialAccountSchema.name)
146
+ const rows = await this.db.query<Record<string, unknown>>(
147
+ `SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
148
+ [userId],
149
+ )
150
+ return Promise.all(rows.map((r) => this.hydrate(r)))
151
+ }
152
+
153
+ /** Single (user, provider) lookup. */
154
+ async findByUserAndProvider(
155
+ userId: string,
156
+ provider: string,
157
+ ): Promise<SocialAccount | null> {
158
+ const table = quoteIdent(socialAccountSchema.name)
159
+ const rows = await this.db.query<Record<string, unknown>>(
160
+ `SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
161
+ [userId, provider],
162
+ )
163
+ if (rows.length === 0) return null
164
+ return this.hydrate(rows[0]!)
165
+ }
166
+
167
+ /**
168
+ * The sign-in lookup: given an OAuth identity, find the user
169
+ * it belongs to. Returns the account row (which includes
170
+ * `user_id`) or `null` when no link exists.
171
+ */
172
+ async findByProviderIdentity(
173
+ provider: string,
174
+ providerUserId: string,
175
+ ): Promise<SocialAccount | null> {
176
+ const table = quoteIdent(socialAccountSchema.name)
177
+ const rows = await this.db.query<Record<string, unknown>>(
178
+ `SELECT * FROM ${table}
179
+ WHERE "provider" = $1 AND "provider_user_id" = $2
180
+ LIMIT 1`,
181
+ [provider, providerUserId],
182
+ )
183
+ if (rows.length === 0) return null
184
+ return this.hydrate(rows[0]!)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Thrown when an OAuth identity is already linked to a DIFFERENT
190
+ * user than the caller is trying to attach it to. Apps catch this
191
+ * and surface a UI ("this Google account is already linked to
192
+ * <other_email>"). The framework refuses to silently move the
193
+ * link or fork the row.
194
+ */
195
+ export class SocialAccountAlreadyLinkedError extends Error {
196
+ readonly provider: string
197
+ readonly providerUserId: string
198
+ readonly existingUserId: string
199
+ readonly attemptedUserId: string
200
+
201
+ constructor(info: {
202
+ provider: string
203
+ providerUserId: string
204
+ existingUserId: string
205
+ attemptedUserId: string
206
+ }) {
207
+ super(
208
+ `SocialAccount: provider "${info.provider}" identity "${info.providerUserId}" is already linked to user "${info.existingUserId}"; refusing to relink to "${info.attemptedUserId}".`,
209
+ )
210
+ this.name = 'SocialAccountAlreadyLinkedError'
211
+ this.provider = info.provider
212
+ this.providerUserId = info.providerUserId
213
+ this.existingUserId = info.existingUserId
214
+ this.attemptedUserId = info.attemptedUserId
215
+ }
216
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `socialAccountSchema` — ledger of provider identities linked
3
+ * to app users. **Non-tenanted by default** (framework policy:
4
+ * multitenancy is opt-in). Apps that need per-tenant scoping
5
+ * import `tenantedSocialAccountSchema` from
6
+ * `@strav/social/tenanted` instead.
7
+ *
8
+ * Natural key is `(provider, provider_user_id)` — a given
9
+ * Google / Line / Facebook identity belongs to exactly one
10
+ * user. Composite uniqueness lives in the migration (the schema
11
+ * builder only exposes per-column `.unique()`).
12
+ *
13
+ * Tokens are encrypted-at-rest via `@strav/database`'s
14
+ * `t.encrypted(...)` column kind + `@encrypt` decorator on the
15
+ * Model. Apps must have an `EncryptionProvider` registered on
16
+ * the kernel container; otherwise the first repository call
17
+ * throws `ConfigError` at runtime.
18
+ *
19
+ * Why store tokens here at all: many apps need long-term
20
+ * offline access (Google `access_type=offline`) or to revoke
21
+ * later. Apps that only need "did this user sign in via X"
22
+ * set the encrypted columns to a sentinel via the Repository's
23
+ * upsert path and discard the real tokens after first use.
24
+ *
25
+ * Columns:
26
+ *
27
+ * - `id` ULID PK.
28
+ * - `user_id` App-side user reference. Free-form
29
+ * string so apps with ULID / int / uuid
30
+ * PKs all fit.
31
+ * - `provider` Driver identifier (`'line'` /
32
+ * `'google'` / `'facebook'` / custom).
33
+ * - `provider_user_id` Provider-native subject id (Google
34
+ * `sub`, Line `userId`, Facebook `id`).
35
+ * - `email` Last known email — cached for app
36
+ * UI; canonical lookups go to the user.
37
+ * - `name` Last known display name.
38
+ * - `avatar_url` Last known avatar URL.
39
+ * - `locale` Last known locale (where the provider
40
+ * gives one; Line doesn't).
41
+ * - `access_token` Encrypted-at-rest.
42
+ * - `refresh_token` Encrypted-at-rest. Nullable (Facebook
43
+ * doesn't issue them).
44
+ * - `id_token` Encrypted-at-rest. Nullable (OIDC
45
+ * providers only).
46
+ * - `expires_at` When the access token expires.
47
+ * - `scope` Space-separated granted scope string.
48
+ * - `metadata` Free-form jsonb (provider extras).
49
+ * - `created_at` / `updated_at`
50
+ */
51
+
52
+ import { Archetype, defineSchema } from '@strav/database'
53
+
54
+ export const socialAccountSchema = defineSchema(
55
+ 'social_account',
56
+ Archetype.Entity,
57
+ (t) => {
58
+ t.id()
59
+ t.string('user_id').max(64).notNull()
60
+ t.string('provider').max(64).notNull()
61
+ t.string('provider_user_id').max(255).notNull()
62
+ t.string('email').max(320).nullable()
63
+ t.string('name').max(255).nullable()
64
+ t.string('avatar_url').max(1024).nullable()
65
+ t.string('locale').max(16).nullable()
66
+ t.encrypted('access_token').notNull()
67
+ t.encrypted('refresh_token').nullable()
68
+ t.encrypted('id_token').nullable()
69
+ t.timestamp('expires_at').nullable()
70
+ t.string('scope').max(512).nullable()
71
+ t.json('metadata').notNull().default({})
72
+ t.timestamp('created_at').notNull()
73
+ t.timestamp('updated_at').notNull()
74
+ },
75
+ )
@@ -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
@@ -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
+ }