@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 +0 -1
- package/package.json +4 -4
- package/src/abstract_provider.ts +49 -38
- package/src/providers/discord_provider.ts +7 -2
- package/src/providers/facebook_provider.ts +21 -3
- package/src/providers/github_provider.ts +12 -3
- package/src/providers/google_provider.ts +7 -2
- package/src/providers/linkedin_provider.ts +7 -2
- package/src/social_account.ts +66 -12
- package/src/types.ts +17 -0
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.
|
|
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.
|
|
20
|
-
"@strav/http": "0.3.
|
|
21
|
-
"@strav/database": "0.3.
|
|
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/",
|
package/src/abstract_provider.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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: '',
|
package/src/social_account.ts
CHANGED
|
@@ -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
|
|
113
|
+
encryptToken(data.token),
|
|
114
|
+
data.refreshToken != null ? encryptToken(data.refreshToken) : null,
|
|
91
115
|
data.expiresAt ?? null,
|
|
92
116
|
]
|
|
93
117
|
)
|
|
94
|
-
|
|
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
|
|
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" = ${
|
|
145
|
-
"refresh_token" = ${
|
|
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
|
-
/**
|
|
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:
|
|
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 {
|