@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.
- package/package.json +23 -16
- package/src/drivers/index.ts +2 -0
- package/src/drivers/mock_driver.ts +170 -0
- package/src/drivers/unsupported.ts +17 -0
- package/src/dto/index.ts +4 -0
- package/src/dto/oauth_tokens.ts +26 -0
- package/src/dto/social_profile.ts +37 -0
- package/src/facebook/facebook_config.ts +68 -0
- package/src/facebook/facebook_driver.ts +321 -0
- package/src/facebook/facebook_provider.ts +29 -0
- package/src/facebook/index.ts +12 -0
- package/src/google/google_config.ts +44 -0
- package/src/google/google_driver.ts +317 -0
- package/src/google/google_provider.ts +33 -0
- package/src/google/index.ts +12 -0
- package/src/index.ts +61 -14
- package/src/ledger/apply_social_account_migration.ts +66 -0
- package/src/ledger/index.ts +12 -0
- package/src/ledger/social_account.ts +32 -0
- package/src/ledger/social_account_repository.ts +216 -0
- package/src/ledger/social_account_schema.ts +75 -0
- package/src/line/index.ts +12 -0
- package/src/line/line_config.ts +47 -0
- package/src/line/line_driver.ts +310 -0
- package/src/line/line_provider.ts +34 -0
- package/src/pkce.ts +63 -0
- package/src/social_capabilities.ts +35 -0
- package/src/social_driver.ts +105 -0
- package/src/social_error.ts +155 -0
- package/src/social_manager.ts +92 -74
- package/src/social_provider.ts +41 -7
- package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
- package/src/tenanted/index.ts +18 -0
- package/src/tenanted/tenanted_social_account.ts +30 -0
- package/src/tenanted/tenanted_social_account_repository.ts +149 -0
- package/src/tenanted/tenanted_social_account_schema.ts +44 -0
- package/src/types.ts +15 -43
- package/CHANGELOG.md +0 -19
- package/README.md +0 -78
- package/src/abstract_provider.ts +0 -182
- package/src/helpers.ts +0 -31
- package/src/providers/discord_provider.ts +0 -59
- package/src/providers/facebook_provider.ts +0 -69
- package/src/providers/github_provider.ts +0 -75
- package/src/providers/google_provider.ts +0 -51
- package/src/providers/line_provider.ts +0 -73
- package/src/providers/linkedin_provider.ts +0 -51
- package/src/schema.ts +0 -13
- package/src/social_account.ts +0 -238
- package/stubs/config/social.ts +0 -22
- package/stubs/schemas/social_account.ts +0 -13
- package/tsconfig.json +0 -5
|
@@ -0,0 +1,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
|
+
}
|