@strav/social 0.3.32 → 0.3.33

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/README.md CHANGED
@@ -57,7 +57,6 @@ r.get('/auth/github/callback', async ctx => {
57
57
  ```ts
58
58
  social.driver('github').scopes(['repo', 'gist']).redirect(ctx)
59
59
  social.driver('google').with({ hd: 'example.com' }).redirect(ctx)
60
- social.driver('google').stateless().redirect(ctx)
61
60
  const user = await social.driver('google').userFromToken(accessToken)
62
61
  ```
63
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/social",
3
- "version": "0.3.32",
3
+ "version": "0.3.33",
4
4
  "type": "module",
5
5
  "description": "OAuth social authentication for the Strav framework",
6
6
  "license": "MIT",
@@ -16,9 +16,9 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "peerDependencies": {
19
- "@strav/kernel": "0.3.32",
20
- "@strav/http": "0.3.32",
21
- "@strav/database": "0.3.32"
19
+ "@strav/kernel": "0.3.33",
20
+ "@strav/http": "0.3.33",
21
+ "@strav/database": "0.3.33"
22
22
  },
23
23
  "scripts": {
24
24
  "test": "bun test tests/",
@@ -1,5 +1,5 @@
1
1
  import type { Context, Session } from '@strav/http'
2
- import { randomHex, ExternalServiceError } from '@strav/kernel'
2
+ import { randomHex, ExternalServiceError, scrubProviderError } from '@strav/kernel'
3
3
  import type { ProviderConfig, SocialUser, TokenResponse } from './types.ts'
4
4
 
5
5
  const STATE_KEY = 'social_state'
@@ -9,7 +9,6 @@ export abstract class AbstractProvider {
9
9
 
10
10
  protected config: ProviderConfig
11
11
  protected _scopes: string[]
12
- protected _stateless = false
13
12
  protected _parameters: Record<string, string> = {}
14
13
 
15
14
  constructor(config: ProviderConfig) {
@@ -41,11 +40,6 @@ export abstract class AbstractProvider {
41
40
  return this
42
41
  }
43
42
 
44
- stateless(): this {
45
- this._stateless = true
46
- return this
47
- }
48
-
49
43
  with(params: Record<string, string>): this {
50
44
  this._parameters = { ...this._parameters, ...params }
51
45
  return this
@@ -56,31 +50,25 @@ export abstract class AbstractProvider {
56
50
  // ---------------------------------------------------------------------------
57
51
 
58
52
  redirect(ctx: Context): Response {
59
- let state: string | undefined
60
-
61
- if (!this._stateless) {
62
- state = randomHex(32)
63
- const session = ctx.get<Session>('session')
64
- session.set(STATE_KEY, state)
65
- }
53
+ const state = randomHex(32)
54
+ const session = ctx.get<Session>('session')
55
+ session.set(STATE_KEY, state)
66
56
 
67
57
  const url = this.buildAuthUrl(state)
68
58
  return ctx.redirect(url)
69
59
  }
70
60
 
71
61
  async user(ctx: Context): Promise<SocialUser> {
72
- if (!this._stateless) {
73
- const session = ctx.get<Session>('session')
74
- const expectedState = session.get<string>(STATE_KEY)
75
- const returnedState = ctx.query.get('state')
62
+ const session = ctx.get<Session>('session')
63
+ const expectedState = session.get<string>(STATE_KEY)
64
+ const returnedState = ctx.query.get('state')
76
65
 
77
- if (!expectedState || expectedState !== returnedState) {
78
- throw new SocialError('Invalid state parameter. Possible CSRF attack.')
79
- }
80
-
81
- session.forget(STATE_KEY)
66
+ if (!expectedState || expectedState !== returnedState) {
67
+ throw new SocialError('Invalid state parameter. Possible CSRF attack.')
82
68
  }
83
69
 
70
+ session.forget(STATE_KEY)
71
+
84
72
  const code = ctx.query.get('code')
85
73
  if (!code) {
86
74
  const error = ctx.query.get('error')
@@ -117,25 +105,49 @@ export abstract class AbstractProvider {
117
105
  // Internal
118
106
  // ---------------------------------------------------------------------------
119
107
 
108
+ /**
109
+ * Per-provider override of the default token-endpoint auth method.
110
+ * Override this in subclasses for providers that require a non-`basic`
111
+ * default (e.g. Facebook prefers `post`).
112
+ */
113
+ protected defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
114
+ return 'basic'
115
+ }
116
+
120
117
  protected async getAccessToken(code: string): Promise<TokenResponse> {
118
+ const method = this.config.tokenEndpointAuthMethod ?? this.defaultTokenEndpointAuthMethod()
119
+
120
+ const headers: Record<string, string> = {
121
+ 'Content-Type': 'application/x-www-form-urlencoded',
122
+ Accept: 'application/json',
123
+ }
124
+
125
+ const body: Record<string, string> = {
126
+ grant_type: 'authorization_code',
127
+ client_id: this.config.clientId,
128
+ code,
129
+ redirect_uri: this.config.redirectUrl,
130
+ }
131
+
132
+ if (method === 'basic') {
133
+ // RFC 6749 §2.3.1 — MUST-support form. Keeps client_secret out of
134
+ // body-logging surfaces.
135
+ const credentials = `${this.config.clientId}:${this.config.clientSecret}`
136
+ headers.Authorization = `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`
137
+ } else {
138
+ // Fallback for providers that only accept secret-in-body.
139
+ body.client_secret = this.config.clientSecret
140
+ }
141
+
121
142
  const response = await fetch(this.getTokenUrl(), {
122
143
  method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/x-www-form-urlencoded',
125
- Accept: 'application/json',
126
- },
127
- body: new URLSearchParams({
128
- grant_type: 'authorization_code',
129
- client_id: this.config.clientId,
130
- client_secret: this.config.clientSecret,
131
- code,
132
- redirect_uri: this.config.redirectUrl,
133
- }),
144
+ headers,
145
+ body: new URLSearchParams(body),
134
146
  })
135
147
 
136
148
  if (!response.ok) {
137
149
  const text = await response.text()
138
- throw new ExternalServiceError(this.name, response.status, text)
150
+ throw new ExternalServiceError(this.name, response.status, scrubProviderError(text))
139
151
  }
140
152
 
141
153
  const data = (await response.json()) as Record<string, unknown>
@@ -148,17 +160,16 @@ export abstract class AbstractProvider {
148
160
  }
149
161
  }
150
162
 
151
- protected buildAuthUrl(state?: string): string {
163
+ protected buildAuthUrl(state: string): string {
152
164
  const params = new URLSearchParams({
153
165
  client_id: this.config.clientId,
154
166
  redirect_uri: this.config.redirectUrl,
155
167
  response_type: 'code',
156
168
  scope: this._scopes.join(' '),
169
+ state,
157
170
  ...this._parameters,
158
171
  })
159
172
 
160
- if (state) params.set('state', state)
161
-
162
173
  return `${this.getAuthUrl()}?${params.toString()}`
163
174
  }
164
175
  }
@@ -1,4 +1,4 @@
1
- import { ExternalServiceError } from '@strav/kernel'
1
+ import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
2
  import { AbstractProvider } from '../abstract_provider.ts'
3
3
  import type { SocialUser } from '../types.ts'
4
4
 
@@ -23,7 +23,11 @@ export class DiscordProvider extends AbstractProvider {
23
23
  })
24
24
 
25
25
  if (!response.ok) {
26
- throw new ExternalServiceError('Discord', response.status, await response.text())
26
+ throw new ExternalServiceError(
27
+ 'Discord',
28
+ response.status,
29
+ scrubProviderError(await response.text())
30
+ )
27
31
  }
28
32
 
29
33
  return (await response.json()) as Record<string, unknown>
@@ -42,6 +46,7 @@ export class DiscordProvider extends AbstractProvider {
42
46
  id: data.id as string,
43
47
  name: (data.global_name as string) ?? null,
44
48
  email: (data.email as string) ?? null,
49
+ emailVerified: data.verified === true,
45
50
  avatar,
46
51
  nickname: (data.username as string) ?? null,
47
52
  token: '',
@@ -1,4 +1,4 @@
1
- import { ExternalServiceError } from '@strav/kernel'
1
+ import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
2
  import { AbstractProvider } from '../abstract_provider.ts'
3
3
  import type { SocialUser } from '../types.ts'
4
4
 
@@ -11,6 +11,16 @@ export class FacebookProvider extends AbstractProvider {
11
11
  return ['email', 'public_profile']
12
12
  }
13
13
 
14
+ /**
15
+ * Facebook's Graph API token endpoint reads `client_secret` from the
16
+ * request body (its docs don't advertise HTTP Basic support reliably),
17
+ * so default to `'post'` here. Apps can still override via
18
+ * `ProviderConfig.tokenEndpointAuthMethod`.
19
+ */
20
+ protected override defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
21
+ return 'post'
22
+ }
23
+
14
24
  protected getAuthUrl(): string {
15
25
  return `https://www.facebook.com/${API_VERSION}/dialog/oauth`
16
26
  }
@@ -26,7 +36,11 @@ export class FacebookProvider extends AbstractProvider {
26
36
  )
27
37
 
28
38
  if (!response.ok) {
29
- throw new ExternalServiceError('Facebook', response.status, await response.text())
39
+ throw new ExternalServiceError(
40
+ 'Facebook',
41
+ response.status,
42
+ scrubProviderError(await response.text())
43
+ )
30
44
  }
31
45
 
32
46
  return (await response.json()) as Record<string, unknown>
@@ -34,11 +48,15 @@ export class FacebookProvider extends AbstractProvider {
34
48
 
35
49
  protected mapUserToObject(data: Record<string, unknown>): SocialUser {
36
50
  const picture = data.picture as { data?: { url?: string } } | undefined
51
+ const email = (data.email as string) ?? null
37
52
 
38
53
  return {
39
54
  id: data.id as string,
40
55
  name: (data.name as string) ?? null,
41
- email: (data.email as string) ?? null,
56
+ email,
57
+ // Facebook's Graph API only returns the user's verified primary email;
58
+ // an unverified address is omitted from the response entirely.
59
+ emailVerified: email !== null,
42
60
  avatar: picture?.data?.url ?? null,
43
61
  nickname: null,
44
62
  token: '',
@@ -1,4 +1,4 @@
1
- import { ExternalServiceError } from '@strav/kernel'
1
+ import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
2
  import { AbstractProvider } from '../abstract_provider.ts'
3
3
  import type { SocialUser } from '../types.ts'
4
4
 
@@ -30,7 +30,11 @@ export class GitHubProvider extends AbstractProvider {
30
30
  ])
31
31
 
32
32
  if (!userResponse.ok) {
33
- throw new ExternalServiceError('GitHub', userResponse.status, await userResponse.text())
33
+ throw new ExternalServiceError(
34
+ 'GitHub',
35
+ userResponse.status,
36
+ scrubProviderError(await userResponse.text())
37
+ )
34
38
  }
35
39
 
36
40
  const user = (await userResponse.json()) as Record<string, unknown>
@@ -50,10 +54,15 @@ export class GitHubProvider extends AbstractProvider {
50
54
  }
51
55
 
52
56
  protected mapUserToObject(data: Record<string, unknown>): SocialUser {
57
+ const email = (data.email as string) ?? null
53
58
  return {
54
59
  id: String(data.id),
55
60
  name: (data.name as string) ?? null,
56
- email: (data.email as string) ?? null,
61
+ email,
62
+ // GitHub only exposes verified emails: the profile `email` is required
63
+ // to be a verified address, and the fallback path in getUserByToken
64
+ // filters /user/emails for `verified === true`.
65
+ emailVerified: email !== null,
57
66
  avatar: (data.avatar_url as string) ?? null,
58
67
  nickname: (data.login as string) ?? null,
59
68
  token: '',
@@ -1,4 +1,4 @@
1
- import { ExternalServiceError } from '@strav/kernel'
1
+ import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
2
  import { AbstractProvider } from '../abstract_provider.ts'
3
3
  import type { SocialUser } from '../types.ts'
4
4
 
@@ -23,7 +23,11 @@ export class GoogleProvider extends AbstractProvider {
23
23
  })
24
24
 
25
25
  if (!response.ok) {
26
- throw new ExternalServiceError('Google', response.status, await response.text())
26
+ throw new ExternalServiceError(
27
+ 'Google',
28
+ response.status,
29
+ scrubProviderError(await response.text())
30
+ )
27
31
  }
28
32
 
29
33
  return (await response.json()) as Record<string, unknown>
@@ -34,6 +38,7 @@ export class GoogleProvider extends AbstractProvider {
34
38
  id: data.sub as string,
35
39
  name: (data.name as string) ?? null,
36
40
  email: (data.email as string) ?? null,
41
+ emailVerified: data.email_verified === true,
37
42
  avatar: (data.picture as string) ?? null,
38
43
  nickname: null,
39
44
  token: '',
@@ -1,4 +1,4 @@
1
- import { ExternalServiceError } from '@strav/kernel'
1
+ import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
2
2
  import { AbstractProvider } from '../abstract_provider.ts'
3
3
  import type { SocialUser } from '../types.ts'
4
4
 
@@ -23,7 +23,11 @@ export class LinkedInProvider extends AbstractProvider {
23
23
  })
24
24
 
25
25
  if (!response.ok) {
26
- throw new ExternalServiceError('LinkedIn', response.status, await response.text())
26
+ throw new ExternalServiceError(
27
+ 'LinkedIn',
28
+ response.status,
29
+ scrubProviderError(await response.text())
30
+ )
27
31
  }
28
32
 
29
33
  return (await response.json()) as Record<string, unknown>
@@ -34,6 +38,7 @@ export class LinkedInProvider extends AbstractProvider {
34
38
  id: data.sub as string,
35
39
  name: (data.name as string) ?? null,
36
40
  email: (data.email as string) ?? null,
41
+ emailVerified: data.email_verified === true,
37
42
  avatar: (data.picture as string) ?? null,
38
43
  nickname: null,
39
44
  token: '',
@@ -1,7 +1,29 @@
1
+ import { EncryptionManager, Emitter } from '@strav/kernel'
1
2
  import { extractUserId } from '@strav/database'
2
3
  import SocialManager from './social_manager.ts'
3
4
  import type { SocialUser } from './types.ts'
4
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
+
5
27
  /** The DB record for a social account link. */
6
28
  export interface SocialAccountData {
7
29
  id: number
@@ -66,7 +88,9 @@ export default class SocialAccount {
66
88
  }
67
89
 
68
90
  /**
69
- * Create a new social account link.
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.
70
94
  */
71
95
  static async create(data: {
72
96
  user: unknown
@@ -86,17 +110,31 @@ export default class SocialAccount {
86
110
  userId,
87
111
  data.provider,
88
112
  data.providerId,
89
- data.token,
90
- data.refreshToken ?? null,
113
+ encryptToken(data.token),
114
+ data.refreshToken != null ? encryptToken(data.refreshToken) : null,
91
115
  data.expiresAt ?? null,
92
116
  ]
93
117
  )
94
- return SocialAccount.hydrate(rows[0] as Record<string, unknown>)
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
95
126
  }
96
127
 
97
128
  /**
98
- * Find an existing social account by provider or create a new one.
99
- * If the account already exists, its tokens are updated.
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.
100
138
  */
101
139
  static async findOrCreate(
102
140
  provider: string,
@@ -131,7 +169,10 @@ export default class SocialAccount {
131
169
  }
132
170
 
133
171
  /**
134
- * Update OAuth tokens for an existing social account.
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.
135
176
  */
136
177
  static async updateTokens(
137
178
  id: number,
@@ -139,21 +180,32 @@ export default class SocialAccount {
139
180
  refreshToken: string | null,
140
181
  expiresAt: Date | null
141
182
  ): Promise<void> {
183
+ const encryptedToken = encryptToken(token)
184
+ const encryptedRefresh = refreshToken != null ? encryptToken(refreshToken) : null
142
185
  await SocialAccount.sql`
143
186
  UPDATE "social_account"
144
- SET "token" = ${token},
145
- "refresh_token" = ${refreshToken},
187
+ SET "token" = ${encryptedToken},
188
+ "refresh_token" = ${encryptedRefresh},
146
189
  "expires_at" = ${expiresAt},
147
190
  "updated_at" = NOW()
148
191
  WHERE "id" = ${id}
149
192
  `
193
+ void Emitter.emit('social_account:tokens_updated', {
194
+ accountId: id,
195
+ hasRefreshToken: refreshToken != null,
196
+ expiresAt,
197
+ }).catch(() => {})
150
198
  }
151
199
 
152
- /** Delete a social account by its database ID. */
200
+ /**
201
+ * Delete a social account by its database ID. Emits
202
+ * `social_account:unlinked` for the audit trail.
203
+ */
153
204
  static async delete(id: number): Promise<void> {
154
205
  await SocialAccount.sql`
155
206
  DELETE FROM "social_account" WHERE "id" = ${id}
156
207
  `
208
+ void Emitter.emit('social_account:unlinked', { accountId: id }).catch(() => {})
157
209
  }
158
210
 
159
211
  /** Delete all social accounts for a user. */
@@ -161,6 +213,7 @@ export default class SocialAccount {
161
213
  const userId = extractUserId(user)
162
214
  const fk = SocialAccount.fk
163
215
  await SocialAccount.sql.unsafe(`DELETE FROM "social_account" WHERE "${fk}" = $1`, [userId])
216
+ void Emitter.emit('social_account:unlinked_all', { userId }).catch(() => {})
164
217
  }
165
218
 
166
219
  // ---------------------------------------------------------------------------
@@ -169,13 +222,14 @@ export default class SocialAccount {
169
222
 
170
223
  private static hydrate(row: Record<string, unknown>): SocialAccountData {
171
224
  const fk = SocialAccount.fk
225
+ const rawRefresh = (row.refresh_token as string) ?? null
172
226
  return {
173
227
  id: row.id as number,
174
228
  userId: row[fk] as string | number,
175
229
  provider: row.provider as string,
176
230
  providerId: row.provider_id as string,
177
- token: row.token as string,
178
- refreshToken: (row.refresh_token as string) ?? null,
231
+ token: decryptToken(row.token as string),
232
+ refreshToken: rawRefresh != null ? decryptToken(rawRefresh) : null,
179
233
  expiresAt: (row.expires_at as Date) ?? null,
180
234
  createdAt: row.created_at as Date,
181
235
  updatedAt: row.updated_at as Date,
package/src/types.ts CHANGED
@@ -2,6 +2,14 @@ export interface SocialUser {
2
2
  id: string
3
3
  name: string | null
4
4
  email: string | null
5
+ /**
6
+ * Whether the provider asserts the email has been verified by the user.
7
+ *
8
+ * Callers MUST check this before using `email` to match an existing
9
+ * application user — linking by an unverified email is a known account-
10
+ * takeover vector. See packages/social/CLAUDE.md ("Verified-email gate").
11
+ */
12
+ emailVerified: boolean
5
13
  avatar: string | null
6
14
  nickname: string | null
7
15
  token: string
@@ -17,6 +25,15 @@ export interface ProviderConfig {
17
25
  clientSecret: string
18
26
  redirectUrl: string
19
27
  scopes?: string[]
28
+ /**
29
+ * How to authenticate with the provider's token endpoint. Default
30
+ * `'basic'` (HTTP Basic auth — RFC 6749 §2.3.1, MUST-support, keeps
31
+ * `client_secret` out of body-logging surfaces). `'post'` falls back
32
+ * to `client_secret` in the request body for providers that don't
33
+ * accept Basic (e.g. Facebook). The `social` package picks the right
34
+ * default per provider but you can override here if needed.
35
+ */
36
+ tokenEndpointAuthMethod?: 'basic' | 'post'
20
37
  }
21
38
 
22
39
  export interface SocialConfig {