@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
@@ -1,73 +0,0 @@
1
- import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
- import { AbstractProvider } from '../abstract_provider.ts'
3
- import type { SocialUser } from '../types.ts'
4
-
5
- /**
6
- * LINE Login OAuth 2.1 provider.
7
- *
8
- * Distinct from the LINE Messaging API (handled by @strav/line) — this is
9
- * the user-facing OAuth flow that lets a website log a user in with their
10
- * LINE account.
11
- *
12
- * Scope notes:
13
- * - `profile` (default) returns userId, displayName, pictureUrl.
14
- * - `openid` (default) returns an ID token alongside the access token.
15
- * - `email` is optional and requires the "Email permission" to be
16
- * approved on the LINE Login channel — uncommon for new apps. The
17
- * SocialUser.email will be null when this scope is not granted.
18
- *
19
- * @see https://developers.line.biz/en/docs/line-login/integrate-line-login/
20
- */
21
- export class LineProvider extends AbstractProvider {
22
- readonly name = 'LINE'
23
-
24
- protected getDefaultScopes(): string[] {
25
- return ['profile', 'openid']
26
- }
27
-
28
- /** LINE expects client_secret in the body, not as HTTP Basic. */
29
- protected override defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
30
- return 'post'
31
- }
32
-
33
- protected getAuthUrl(): string {
34
- return 'https://access.line.me/oauth2/v2.1/authorize'
35
- }
36
-
37
- protected getTokenUrl(): string {
38
- return 'https://api.line.me/oauth2/v2.1/token'
39
- }
40
-
41
- protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
42
- const response = await fetch('https://api.line.me/v2/profile', {
43
- headers: { Authorization: `Bearer ${token}` },
44
- })
45
-
46
- if (!response.ok) {
47
- throw new ExternalServiceError(
48
- 'LINE',
49
- response.status,
50
- scrubProviderError(await response.text())
51
- )
52
- }
53
-
54
- return (await response.json()) as Record<string, unknown>
55
- }
56
-
57
- protected mapUserToObject(data: Record<string, unknown>): SocialUser {
58
- return {
59
- id: data.userId as string,
60
- name: (data.displayName as string) ?? null,
61
- email: (data.email as string) ?? null,
62
- // LINE does not surface a "verified" flag on email; treat presence as verified.
63
- emailVerified: typeof data.email === 'string',
64
- avatar: (data.pictureUrl as string) ?? null,
65
- nickname: null,
66
- token: '',
67
- refreshToken: null,
68
- expiresIn: null,
69
- approvedScopes: [],
70
- raw: data,
71
- }
72
- }
73
- }
@@ -1,51 +0,0 @@
1
- import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
- import { AbstractProvider } from '../abstract_provider.ts'
3
- import type { SocialUser } from '../types.ts'
4
-
5
- export class LinkedInProvider extends AbstractProvider {
6
- readonly name = 'LinkedIn'
7
-
8
- protected getDefaultScopes(): string[] {
9
- return ['openid', 'profile', 'email']
10
- }
11
-
12
- protected getAuthUrl(): string {
13
- return 'https://www.linkedin.com/oauth/v2/authorization'
14
- }
15
-
16
- protected getTokenUrl(): string {
17
- return 'https://www.linkedin.com/oauth/v2/accessToken'
18
- }
19
-
20
- protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
21
- const response = await fetch('https://api.linkedin.com/v2/userinfo', {
22
- headers: { Authorization: `Bearer ${token}` },
23
- })
24
-
25
- if (!response.ok) {
26
- throw new ExternalServiceError(
27
- 'LinkedIn',
28
- response.status,
29
- scrubProviderError(await response.text())
30
- )
31
- }
32
-
33
- return (await response.json()) as Record<string, unknown>
34
- }
35
-
36
- protected mapUserToObject(data: Record<string, unknown>): SocialUser {
37
- return {
38
- id: data.sub as string,
39
- name: (data.name as string) ?? null,
40
- email: (data.email as string) ?? null,
41
- emailVerified: data.email_verified === true,
42
- avatar: (data.picture as string) ?? null,
43
- nickname: null,
44
- token: '',
45
- refreshToken: null,
46
- expiresIn: null,
47
- approvedScopes: [],
48
- raw: data,
49
- }
50
- }
51
- }
package/src/schema.ts DELETED
@@ -1,13 +0,0 @@
1
- import { defineSchema, t, Archetype } from '@strav/database'
2
-
3
- export const schema = defineSchema('social_account', {
4
- archetype: Archetype.Component,
5
- parents: ['user'],
6
- fields: {
7
- provider: t.varchar(50).required().index(),
8
- providerId: t.varchar(255).required().index(),
9
- token: t.text().required().sensitive(),
10
- refreshToken: t.text().nullable().sensitive(),
11
- expiresAt: t.timestamptz().nullable(),
12
- },
13
- })
@@ -1,238 +0,0 @@
1
- import { EncryptionManager, Emitter } from '@strav/kernel'
2
- import { extractUserId } from '@strav/database'
3
- import SocialManager from './social_manager.ts'
4
- import type { SocialUser } from './types.ts'
5
-
6
- const ENC_PREFIX = 'enc:v1:'
7
-
8
- /**
9
- * Encrypt an OAuth token before persisting it. The `enc:v1:` prefix is the
10
- * sentinel that lets reads distinguish encrypted values from legacy
11
- * plaintext rows that predate the encryption-at-rest migration.
12
- */
13
- function encryptToken(plain: string): string {
14
- return ENC_PREFIX + EncryptionManager.encrypt(plain)
15
- }
16
-
17
- /**
18
- * Decrypt a stored token. Values without the `enc:v1:` prefix are assumed
19
- * to be legacy plaintext (predate encryption-at-rest); they are returned
20
- * as-is and re-encrypted on next write.
21
- */
22
- function decryptToken(stored: string): string {
23
- if (!stored.startsWith(ENC_PREFIX)) return stored
24
- return EncryptionManager.decrypt(stored.slice(ENC_PREFIX.length))
25
- }
26
-
27
- /** The DB record for a social account link. */
28
- export interface SocialAccountData {
29
- id: number
30
- userId: string | number
31
- provider: string
32
- providerId: string
33
- token: string
34
- refreshToken: string | null
35
- expiresAt: Date | null
36
- createdAt: Date
37
- updatedAt: Date
38
- }
39
-
40
- /**
41
- * Static helper for managing social account records.
42
- *
43
- * Follows the same pattern as AccessToken: all methods are static,
44
- * database access goes through the parent manager (SocialManager.db).
45
- *
46
- * @example
47
- * const account = await SocialAccount.findByProvider('github', '12345')
48
- * const accounts = await SocialAccount.findByUser(user)
49
- * const created = await SocialAccount.create({ user, provider: 'google', ... })
50
- */
51
- export default class SocialAccount {
52
- private static get sql() {
53
- return SocialManager.db.sql
54
- }
55
-
56
- private static get fk() {
57
- return SocialManager.userFkColumn
58
- }
59
-
60
- /**
61
- * Find a social account by provider name and provider-specific user ID.
62
- * This is the primary lookup used during OAuth callback.
63
- */
64
- static async findByProvider(
65
- provider: string,
66
- providerId: string
67
- ): Promise<SocialAccountData | null> {
68
- const rows = await SocialAccount.sql`
69
- SELECT * FROM "social_account"
70
- WHERE "provider" = ${provider}
71
- AND "provider_id" = ${providerId}
72
- LIMIT 1
73
- `
74
- return rows.length > 0 ? SocialAccount.hydrate(rows[0] as Record<string, unknown>) : null
75
- }
76
-
77
- /**
78
- * Find all social accounts linked to a user.
79
- */
80
- static async findByUser(user: unknown): Promise<SocialAccountData[]> {
81
- const userId = extractUserId(user)
82
- const fk = SocialAccount.fk
83
- const rows = await SocialAccount.sql.unsafe(
84
- `SELECT * FROM "social_account" WHERE "${fk}" = $1 ORDER BY "created_at" ASC`,
85
- [userId]
86
- )
87
- return rows.map((r: any) => SocialAccount.hydrate(r))
88
- }
89
-
90
- /**
91
- * Create a new social account link. Emits `social_account:linked`
92
- * after a successful insert so apps can wire `@strav/audit` (or any
93
- * other observability sink) without forcing a hard dependency.
94
- */
95
- static async create(data: {
96
- user: unknown
97
- provider: string
98
- providerId: string
99
- token: string
100
- refreshToken?: string | null
101
- expiresAt?: Date | null
102
- }): Promise<SocialAccountData> {
103
- const userId = extractUserId(data.user)
104
- const fk = SocialAccount.fk
105
- const rows = await SocialAccount.sql.unsafe(
106
- `INSERT INTO "social_account" ("${fk}", "provider", "provider_id", "token", "refresh_token", "expires_at")
107
- VALUES ($1, $2, $3, $4, $5, $6)
108
- RETURNING *`,
109
- [
110
- userId,
111
- data.provider,
112
- data.providerId,
113
- encryptToken(data.token),
114
- data.refreshToken != null ? encryptToken(data.refreshToken) : null,
115
- data.expiresAt ?? null,
116
- ]
117
- )
118
- const account = SocialAccount.hydrate(rows[0] as Record<string, unknown>)
119
- void Emitter.emit('social_account:linked', {
120
- accountId: account.id,
121
- userId: account.userId,
122
- provider: account.provider,
123
- providerId: account.providerId,
124
- }).catch(() => {})
125
- return account
126
- }
127
-
128
- /**
129
- * Find an existing social account by `(provider, providerId)` or create a
130
- * new one. If the account already exists, its tokens are updated.
131
- *
132
- * SECURITY: This function does NOT validate the email. If the caller is
133
- * passing in an existing application `user` that was located by
134
- * `socialUser.email`, the caller MUST first verify
135
- * `socialUser.emailVerified === true`. Linking by an unverified
136
- * provider email is a known account-takeover vector — see the
137
- * "Verified-email gate" section in this package's CLAUDE.md.
138
- */
139
- static async findOrCreate(
140
- provider: string,
141
- socialUser: SocialUser,
142
- user: unknown
143
- ): Promise<{ account: SocialAccountData; created: boolean }> {
144
- const existing = await SocialAccount.findByProvider(provider, socialUser.id)
145
- if (existing) {
146
- await SocialAccount.updateTokens(
147
- existing.id,
148
- socialUser.token,
149
- socialUser.refreshToken,
150
- socialUser.expiresIn ? new Date(Date.now() + socialUser.expiresIn * 1000) : null
151
- )
152
- existing.token = socialUser.token
153
- existing.refreshToken = socialUser.refreshToken
154
- existing.expiresAt = socialUser.expiresIn
155
- ? new Date(Date.now() + socialUser.expiresIn * 1000)
156
- : null
157
- return { account: existing, created: false }
158
- }
159
-
160
- const account = await SocialAccount.create({
161
- user,
162
- provider,
163
- providerId: socialUser.id,
164
- token: socialUser.token,
165
- refreshToken: socialUser.refreshToken,
166
- expiresAt: socialUser.expiresIn ? new Date(Date.now() + socialUser.expiresIn * 1000) : null,
167
- })
168
- return { account, created: true }
169
- }
170
-
171
- /**
172
- * Update OAuth tokens for an existing social account. Tokens are
173
- * encrypted at rest — pass plaintext values; the column stores ciphertext.
174
- * Emits `social_account:tokens_updated` so an audit hook can record the
175
- * token swap.
176
- */
177
- static async updateTokens(
178
- id: number,
179
- token: string,
180
- refreshToken: string | null,
181
- expiresAt: Date | null
182
- ): Promise<void> {
183
- const encryptedToken = encryptToken(token)
184
- const encryptedRefresh = refreshToken != null ? encryptToken(refreshToken) : null
185
- await SocialAccount.sql`
186
- UPDATE "social_account"
187
- SET "token" = ${encryptedToken},
188
- "refresh_token" = ${encryptedRefresh},
189
- "expires_at" = ${expiresAt},
190
- "updated_at" = NOW()
191
- WHERE "id" = ${id}
192
- `
193
- void Emitter.emit('social_account:tokens_updated', {
194
- accountId: id,
195
- hasRefreshToken: refreshToken != null,
196
- expiresAt,
197
- }).catch(() => {})
198
- }
199
-
200
- /**
201
- * Delete a social account by its database ID. Emits
202
- * `social_account:unlinked` for the audit trail.
203
- */
204
- static async delete(id: number): Promise<void> {
205
- await SocialAccount.sql`
206
- DELETE FROM "social_account" WHERE "id" = ${id}
207
- `
208
- void Emitter.emit('social_account:unlinked', { accountId: id }).catch(() => {})
209
- }
210
-
211
- /** Delete all social accounts for a user. */
212
- static async deleteByUser(user: unknown): Promise<void> {
213
- const userId = extractUserId(user)
214
- const fk = SocialAccount.fk
215
- await SocialAccount.sql.unsafe(`DELETE FROM "social_account" WHERE "${fk}" = $1`, [userId])
216
- void Emitter.emit('social_account:unlinked_all', { userId }).catch(() => {})
217
- }
218
-
219
- // ---------------------------------------------------------------------------
220
- // Internal
221
- // ---------------------------------------------------------------------------
222
-
223
- private static hydrate(row: Record<string, unknown>): SocialAccountData {
224
- const fk = SocialAccount.fk
225
- const rawRefresh = (row.refresh_token as string) ?? null
226
- return {
227
- id: row.id as number,
228
- userId: row[fk] as string | number,
229
- provider: row.provider as string,
230
- providerId: row.provider_id as string,
231
- token: decryptToken(row.token as string),
232
- refreshToken: rawRefresh != null ? decryptToken(rawRefresh) : null,
233
- expiresAt: (row.expires_at as Date) ?? null,
234
- createdAt: row.created_at as Date,
235
- updatedAt: row.updated_at as Date,
236
- }
237
- }
238
- }
@@ -1,22 +0,0 @@
1
- import { env } from '@strav/kernel'
2
-
3
- export default {
4
- userKey: 'id',
5
- providers: {
6
- // google: {
7
- // clientId: env('GOOGLE_CLIENT_ID', ''),
8
- // clientSecret: env('GOOGLE_CLIENT_SECRET', ''),
9
- // redirectUrl: env('GOOGLE_REDIRECT_URL', 'http://localhost:3000/auth/google/callback'),
10
- // },
11
- // github: {
12
- // clientId: env('GITHUB_CLIENT_ID', ''),
13
- // clientSecret: env('GITHUB_CLIENT_SECRET', ''),
14
- // redirectUrl: env('GITHUB_REDIRECT_URL', 'http://localhost:3000/auth/github/callback'),
15
- // },
16
- // discord: {
17
- // clientId: env('DISCORD_CLIENT_ID', ''),
18
- // clientSecret: env('DISCORD_CLIENT_SECRET', ''),
19
- // redirectUrl: env('DISCORD_REDIRECT_URL', 'http://localhost:3000/auth/discord/callback'),
20
- // },
21
- },
22
- }
@@ -1,13 +0,0 @@
1
- import { defineSchema, t, Archetype } from '@strav/database'
2
-
3
- export default defineSchema('social_account', {
4
- archetype: Archetype.Component,
5
- parents: ['user'],
6
- fields: {
7
- provider: t.varchar(50).required().index(),
8
- providerId: t.varchar(255).required().index(),
9
- token: t.text().required().sensitive(),
10
- refreshToken: t.text().nullable().sensitive(),
11
- expiresAt: t.timestamptz().nullable(),
12
- },
13
- })
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }