@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,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GoogleSocialDriver` — Google Sign-In (OAuth 2.0 + OIDC) via
|
|
3
|
+
* the standard Web application client type.
|
|
4
|
+
*
|
|
5
|
+
* Notes worth knowing:
|
|
6
|
+
*
|
|
7
|
+
* - **Scopes**: `openid` (id_token), `profile`, `email`.
|
|
8
|
+
* Google accepts both short (`'email'`) and fully-qualified
|
|
9
|
+
* (`'https://www.googleapis.com/auth/userinfo.email'`) forms;
|
|
10
|
+
* we use the short forms.
|
|
11
|
+
*
|
|
12
|
+
* - **PKCE**: supported. Mandatory for `installed` / SPA
|
|
13
|
+
* client types; optional for `Web application`. The driver
|
|
14
|
+
* defaults PKCE on as defence in depth (matches Line + the
|
|
15
|
+
* OAuth 2.1 trajectory).
|
|
16
|
+
*
|
|
17
|
+
* - **Refresh tokens**: Google only issues `refresh_token` on
|
|
18
|
+
* `access_type=offline`. The driver defaults to including
|
|
19
|
+
* it. A `refresh_token` is returned ONLY on the user's
|
|
20
|
+
* first consent unless `prompt=consent` forces re-consent.
|
|
21
|
+
* Apps re-establishing offline access after revocation pass
|
|
22
|
+
* `extra: { prompt: 'consent' }`.
|
|
23
|
+
*
|
|
24
|
+
* - **id_token**: returned when `openid` is in the requested
|
|
25
|
+
* scope set. Decoding helper provided for `email` extraction,
|
|
26
|
+
* mirroring `emailFromLineIdToken`. Signature verification
|
|
27
|
+
* deferred to apps (use Google's `tokeninfo` endpoint or a
|
|
28
|
+
* JWT library + Google's JWKS).
|
|
29
|
+
*
|
|
30
|
+
* - **`hd` (hosted domain)**: Google Workspace constraint —
|
|
31
|
+
* pass `extra: { hd: 'example.com' }` on authorize to limit
|
|
32
|
+
* consent to one Workspace domain.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
|
|
36
|
+
import type { SocialCapability } from '../social_capabilities.ts'
|
|
37
|
+
import type {
|
|
38
|
+
AuthorizeInput,
|
|
39
|
+
AuthorizeResult,
|
|
40
|
+
ExchangeInput,
|
|
41
|
+
RefreshInput,
|
|
42
|
+
SocialDriver,
|
|
43
|
+
} from '../social_driver.ts'
|
|
44
|
+
import {
|
|
45
|
+
InvalidTokenError,
|
|
46
|
+
OAuthExchangeError,
|
|
47
|
+
SocialProviderError,
|
|
48
|
+
StateMismatchError,
|
|
49
|
+
} from '../social_error.ts'
|
|
50
|
+
import { codeChallengeFor, randomCodeVerifier, randomState } from '../pkce.ts'
|
|
51
|
+
import { GOOGLE_ENDPOINTS, type GoogleProviderConfig } from './google_config.ts'
|
|
52
|
+
|
|
53
|
+
const PROVIDER = 'google'
|
|
54
|
+
|
|
55
|
+
const CAPS: readonly SocialCapability[] = [
|
|
56
|
+
'openid', 'pkce.support',
|
|
57
|
+
'profile.id', 'profile.email', 'profile.emailVerified',
|
|
58
|
+
'profile.name', 'profile.avatar', 'profile.locale',
|
|
59
|
+
'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
|
|
60
|
+
'scopes.discoverable',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const SCOPES: readonly string[] = ['openid', 'profile', 'email']
|
|
64
|
+
|
|
65
|
+
export interface GoogleDriverOptions {
|
|
66
|
+
instanceName: string
|
|
67
|
+
config: GoogleProviderConfig
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TokenResponse {
|
|
71
|
+
access_token: string
|
|
72
|
+
expires_in: number
|
|
73
|
+
id_token?: string
|
|
74
|
+
refresh_token?: string
|
|
75
|
+
scope?: string
|
|
76
|
+
token_type: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface UserInfoResponse {
|
|
80
|
+
sub: string
|
|
81
|
+
email?: string
|
|
82
|
+
email_verified?: boolean
|
|
83
|
+
name?: string
|
|
84
|
+
given_name?: string
|
|
85
|
+
family_name?: string
|
|
86
|
+
picture?: string
|
|
87
|
+
locale?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface JwtPayload {
|
|
91
|
+
email?: string
|
|
92
|
+
email_verified?: boolean
|
|
93
|
+
sub?: string
|
|
94
|
+
[k: string]: unknown
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class GoogleSocialDriver implements SocialDriver {
|
|
98
|
+
readonly name = PROVIDER
|
|
99
|
+
readonly instanceName: string
|
|
100
|
+
readonly capabilities: ReadonlySet<SocialCapability> = new Set(CAPS)
|
|
101
|
+
readonly availableScopes = SCOPES
|
|
102
|
+
|
|
103
|
+
private readonly config: GoogleProviderConfig
|
|
104
|
+
private readonly fetchFn: typeof fetch
|
|
105
|
+
private readonly endpoints: {
|
|
106
|
+
authorize: string
|
|
107
|
+
token: string
|
|
108
|
+
userInfo: string
|
|
109
|
+
revoke: string
|
|
110
|
+
tokenInfo: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
constructor(options: GoogleDriverOptions) {
|
|
114
|
+
this.instanceName = options.instanceName
|
|
115
|
+
this.config = options.config
|
|
116
|
+
this.fetchFn = options.config.fetch ?? fetch
|
|
117
|
+
this.endpoints = { ...GOOGLE_ENDPOINTS, ...(options.config.endpoints ?? {}) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
121
|
+
const state = input.state ?? randomState()
|
|
122
|
+
const optOut = input.extra?.no_pkce === '1'
|
|
123
|
+
const codeVerifier = optOut
|
|
124
|
+
? undefined
|
|
125
|
+
: input.codeVerifier ?? randomCodeVerifier()
|
|
126
|
+
const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
|
|
127
|
+
|
|
128
|
+
const scopes = input.scopes ?? ['openid', 'profile', 'email']
|
|
129
|
+
const offline = this.config.offlineAccess !== false
|
|
130
|
+
|
|
131
|
+
const params = new URLSearchParams({
|
|
132
|
+
response_type: 'code',
|
|
133
|
+
client_id: this.config.clientId,
|
|
134
|
+
redirect_uri: input.redirectUri,
|
|
135
|
+
scope: scopes.join(' '),
|
|
136
|
+
state,
|
|
137
|
+
...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
|
|
138
|
+
...(offline ? { access_type: 'offline' } : {}),
|
|
139
|
+
// `include_granted_scopes=true` makes Google merge previous
|
|
140
|
+
// grants rather than replace — apps that progressively
|
|
141
|
+
// request scopes appreciate it; safe-by-default.
|
|
142
|
+
include_granted_scopes: 'true',
|
|
143
|
+
...(input.extra ?? {}),
|
|
144
|
+
})
|
|
145
|
+
params.delete('no_pkce')
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
url: `${this.endpoints.authorize}?${params.toString()}`,
|
|
149
|
+
state,
|
|
150
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
155
|
+
if (input.expectedState !== undefined && input.state !== input.expectedState) {
|
|
156
|
+
throw new StateMismatchError()
|
|
157
|
+
}
|
|
158
|
+
const body = new URLSearchParams({
|
|
159
|
+
grant_type: 'authorization_code',
|
|
160
|
+
code: input.code,
|
|
161
|
+
redirect_uri: input.redirectUri,
|
|
162
|
+
client_id: this.config.clientId,
|
|
163
|
+
client_secret: this.config.clientSecret,
|
|
164
|
+
...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}),
|
|
165
|
+
})
|
|
166
|
+
const res = await this.fetchFn(this.endpoints.token, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
169
|
+
body,
|
|
170
|
+
})
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const text = await res.text()
|
|
173
|
+
throw new OAuthExchangeError(
|
|
174
|
+
`GoogleSocialDriver.exchange: token endpoint returned ${res.status}.`,
|
|
175
|
+
{ context: { status: res.status, body: text } },
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return this.toOAuthTokens((await res.json()) as TokenResponse)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async profile(accessToken: string): Promise<SocialProfile> {
|
|
182
|
+
const res = await this.fetchFn(this.endpoints.userInfo, {
|
|
183
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
184
|
+
})
|
|
185
|
+
if (res.status === 401) {
|
|
186
|
+
throw new InvalidTokenError('GoogleSocialDriver.profile: access token rejected.')
|
|
187
|
+
}
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const text = await res.text()
|
|
190
|
+
throw new SocialProviderError(
|
|
191
|
+
`GoogleSocialDriver.profile: userinfo endpoint returned ${res.status}.`,
|
|
192
|
+
{ provider: PROVIDER, operation: 'profile', context: { status: res.status, body: text } },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const u = (await res.json()) as UserInfoResponse
|
|
196
|
+
return {
|
|
197
|
+
id: u.sub,
|
|
198
|
+
provider: PROVIDER,
|
|
199
|
+
...(u.email ? { email: u.email } : {}),
|
|
200
|
+
...(u.email_verified !== undefined ? { emailVerified: u.email_verified } : {}),
|
|
201
|
+
...(u.name ? { name: u.name } : {}),
|
|
202
|
+
...(u.picture ? { avatarUrl: u.picture } : {}),
|
|
203
|
+
...(u.locale ? { locale: u.locale } : {}),
|
|
204
|
+
metadata: {
|
|
205
|
+
...(u.given_name ? { givenName: u.given_name } : {}),
|
|
206
|
+
...(u.family_name ? { familyName: u.family_name } : {}),
|
|
207
|
+
},
|
|
208
|
+
raw: u,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
213
|
+
const body = new URLSearchParams({
|
|
214
|
+
grant_type: 'refresh_token',
|
|
215
|
+
refresh_token: input.refreshToken,
|
|
216
|
+
client_id: this.config.clientId,
|
|
217
|
+
client_secret: this.config.clientSecret,
|
|
218
|
+
})
|
|
219
|
+
const res = await this.fetchFn(this.endpoints.token, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
222
|
+
body,
|
|
223
|
+
})
|
|
224
|
+
if (res.status === 400 || res.status === 401) {
|
|
225
|
+
const text = await res.text()
|
|
226
|
+
throw new InvalidTokenError(
|
|
227
|
+
`GoogleSocialDriver.refresh: refresh token rejected.`,
|
|
228
|
+
{ context: { status: res.status, body: text } },
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
if (!res.ok) {
|
|
232
|
+
const text = await res.text()
|
|
233
|
+
throw new SocialProviderError(
|
|
234
|
+
`GoogleSocialDriver.refresh: token endpoint returned ${res.status}.`,
|
|
235
|
+
{ provider: PROVIDER, operation: 'refresh', context: { status: res.status, body: text } },
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
// Google does NOT rotate refresh tokens; preserve the caller's
|
|
239
|
+
// current refresh token if the response omits it.
|
|
240
|
+
const json = (await res.json()) as TokenResponse
|
|
241
|
+
const tokens = this.toOAuthTokens(json)
|
|
242
|
+
if (!tokens.refreshToken) tokens.refreshToken = input.refreshToken
|
|
243
|
+
return tokens
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async revoke(token: string): Promise<void> {
|
|
247
|
+
// Google's revoke endpoint takes the token as a query
|
|
248
|
+
// parameter OR a form body; we POST form for consistency
|
|
249
|
+
// with the rest of the driver.
|
|
250
|
+
const body = new URLSearchParams({ token })
|
|
251
|
+
const res = await this.fetchFn(this.endpoints.revoke, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
254
|
+
body,
|
|
255
|
+
})
|
|
256
|
+
if (!res.ok) {
|
|
257
|
+
const text = await res.text()
|
|
258
|
+
throw new SocialProviderError(
|
|
259
|
+
`GoogleSocialDriver.revoke: revoke endpoint returned ${res.status}.`,
|
|
260
|
+
{ provider: PROVIDER, operation: 'revoke', context: { status: res.status, body: text } },
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── Internals ────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
private toOAuthTokens(t: TokenResponse): OAuthTokens {
|
|
268
|
+
const expiresAt =
|
|
269
|
+
typeof t.expires_in === 'number'
|
|
270
|
+
? new Date(Date.now() + t.expires_in * 1000)
|
|
271
|
+
: undefined
|
|
272
|
+
return {
|
|
273
|
+
accessToken: t.access_token,
|
|
274
|
+
...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
|
|
275
|
+
...(t.id_token ? { idToken: t.id_token } : {}),
|
|
276
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
277
|
+
...(t.scope ? { scope: t.scope } : {}),
|
|
278
|
+
tokenType: t.token_type ?? 'Bearer',
|
|
279
|
+
raw: t,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Extract the `email` claim from a Google id_token (JWT). Same
|
|
286
|
+
* semantics as `emailFromLineIdToken` — decode-only, no JWS
|
|
287
|
+
* signature verification. Apps with stricter posture verify via
|
|
288
|
+
* Google's `tokeninfo?id_token=...` endpoint.
|
|
289
|
+
*
|
|
290
|
+
* Google's userinfo endpoint also returns `email` + `email_verified`,
|
|
291
|
+
* so most apps don't need this helper — it's here for paths that
|
|
292
|
+
* skip userinfo (e.g. server-only signin where the access token
|
|
293
|
+
* is discarded immediately after exchange).
|
|
294
|
+
*/
|
|
295
|
+
export function emailFromGoogleIdToken(idToken: string): string | null {
|
|
296
|
+
const segments = idToken.split('.')
|
|
297
|
+
if (segments.length !== 3) {
|
|
298
|
+
throw new InvalidTokenError(
|
|
299
|
+
'emailFromGoogleIdToken: id_token does not have 3 segments.',
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
const payload = decodeJwtSegment(segments[1]!)
|
|
303
|
+
return typeof payload.email === 'string' ? payload.email : null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function decodeJwtSegment(segment: string): JwtPayload {
|
|
307
|
+
const pad = segment.length % 4
|
|
308
|
+
const padded = pad === 0 ? segment : `${segment}${'='.repeat(4 - pad)}`
|
|
309
|
+
const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(atob(b64)) as JwtPayload
|
|
312
|
+
} catch (cause) {
|
|
313
|
+
throw new InvalidTokenError('decodeJwtSegment: failed to parse JWT segment.', {
|
|
314
|
+
cause,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GoogleSocialProvider` — `ServiceProvider` that registers the
|
|
3
|
+
* Google driver factory on the `SocialManager`.
|
|
4
|
+
*
|
|
5
|
+
* Listed AFTER `SocialProvider` in `bootstrap/providers.ts`. The
|
|
6
|
+
* factory is invoked lazily on first `social.use(name)`;
|
|
7
|
+
* misconfigured credentials surface on first use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
11
|
+
import { SocialConfigError } from '../social_error.ts'
|
|
12
|
+
import { SocialManager } from '../social_manager.ts'
|
|
13
|
+
import type { GoogleProviderConfig } from './google_config.ts'
|
|
14
|
+
import { GoogleSocialDriver } from './google_driver.ts'
|
|
15
|
+
|
|
16
|
+
export class GoogleSocialProvider extends ServiceProvider {
|
|
17
|
+
override readonly name = 'social-google'
|
|
18
|
+
override readonly dependencies = ['social']
|
|
19
|
+
|
|
20
|
+
override register(app: Application): void {
|
|
21
|
+
const manager = app.resolve(SocialManager)
|
|
22
|
+
manager.extend('google', ({ instanceName, config }) => {
|
|
23
|
+
const cfg = config as GoogleProviderConfig
|
|
24
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
25
|
+
throw new SocialConfigError(
|
|
26
|
+
`GoogleSocialProvider: \`clientId\` and \`clientSecret\` are required for provider "${instanceName}".`,
|
|
27
|
+
{ context: { instanceName } },
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return new GoogleSocialDriver({ instanceName, config: cfg })
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Public API of `@strav/social/google`.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
GOOGLE_ENDPOINTS,
|
|
5
|
+
type GoogleProviderConfig,
|
|
6
|
+
} from './google_config.ts'
|
|
7
|
+
export {
|
|
8
|
+
emailFromGoogleIdToken,
|
|
9
|
+
GoogleSocialDriver,
|
|
10
|
+
type GoogleDriverOptions,
|
|
11
|
+
} from './google_driver.ts'
|
|
12
|
+
export { GoogleSocialProvider } from './google_provider.ts'
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
// Public API of `@strav/social`.
|
|
2
|
+
//
|
|
3
|
+
// V1: provider-agnostic OAuth/OIDC client — normalized profile +
|
|
4
|
+
// token DTOs + multi-provider routing + state + PKCE helpers.
|
|
5
|
+
// Composes with `@strav/kernel` for the container and
|
|
6
|
+
// `@strav/http` (no direct import; for the eventual route helpers).
|
|
7
|
+
//
|
|
8
|
+
// Drivers ship as subpath imports:
|
|
9
|
+
// `@strav/social/line`, `@strav/social/google`,
|
|
10
|
+
// `@strav/social/facebook`. The `MockDriver` in `./drivers`
|
|
11
|
+
// is for tests + as the reference contract.
|
|
12
|
+
//
|
|
13
|
+
// Account-linking schema lives in `./ledger/`:
|
|
14
|
+
// - `social_account` tenanted table (tokens encrypted via
|
|
15
|
+
// kernel's cipher)
|
|
16
|
+
// - `SocialAccountRepository` with connect / disconnect /
|
|
17
|
+
// find{ByUser,ByProviderIdentity} helpers
|
|
18
|
+
// - `applySocialAccountMigration` for the table + composite
|
|
19
|
+
// unique + user_id index
|
|
2
20
|
|
|
3
|
-
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
21
|
+
export type * from './dto/index.ts'
|
|
22
|
+
export { MockDriver, type MockDriverOptions, unsupported } from './drivers/index.ts'
|
|
23
|
+
export {
|
|
24
|
+
applySocialAccountMigration,
|
|
25
|
+
type ApplySocialAccountMigrationOptions,
|
|
26
|
+
type ConnectInput,
|
|
27
|
+
type DisconnectInput,
|
|
28
|
+
SocialAccount,
|
|
29
|
+
SocialAccountAlreadyLinkedError,
|
|
30
|
+
SocialAccountRepository,
|
|
31
|
+
socialAccountSchema,
|
|
32
|
+
} from './ledger/index.ts'
|
|
33
|
+
export {
|
|
34
|
+
codeChallengeFor,
|
|
35
|
+
randomCodeVerifier,
|
|
36
|
+
randomState,
|
|
37
|
+
} from './pkce.ts'
|
|
38
|
+
export type { SocialCapability } from './social_capabilities.ts'
|
|
39
|
+
export type {
|
|
40
|
+
AuthorizeInput,
|
|
41
|
+
AuthorizeResult,
|
|
42
|
+
ExchangeInput,
|
|
43
|
+
RefreshInput,
|
|
44
|
+
SocialDriver,
|
|
45
|
+
SocialDriverFactory,
|
|
46
|
+
} from './social_driver.ts'
|
|
47
|
+
export {
|
|
48
|
+
InvalidTokenError,
|
|
49
|
+
OAuthExchangeError,
|
|
50
|
+
ProviderUnsupportedError,
|
|
51
|
+
SocialConfigError,
|
|
52
|
+
SocialError,
|
|
53
|
+
SocialProviderError,
|
|
54
|
+
StateMismatchError,
|
|
55
|
+
UnknownProviderError,
|
|
56
|
+
} from './social_error.ts'
|
|
57
|
+
export {
|
|
58
|
+
SocialManager,
|
|
59
|
+
type SocialManagerOptions,
|
|
60
|
+
} from './social_manager.ts'
|
|
61
|
+
export { SocialProvider } from './social_provider.ts'
|
|
62
|
+
export type { ProviderConfig, SocialConfig } from './types.ts'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applySocialAccountMigration` — emit DDL for the
|
|
3
|
+
* `social_account` table plus its composite unique constraints
|
|
4
|
+
* and the `user_id` lookup index.
|
|
5
|
+
*
|
|
6
|
+
* Non-tenanted by default (framework policy: multitenancy is
|
|
7
|
+
* opt-in). Apps that need per-tenant scoping use
|
|
8
|
+
* `applyTenantedSocialAccountMigration` from
|
|
9
|
+
* `@strav/social/tenanted` instead.
|
|
10
|
+
*
|
|
11
|
+
* Apps drop one call into their migration:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* export const migration: Migration = {
|
|
15
|
+
* name: '20260601000000_create_social_account',
|
|
16
|
+
* async up(db) {
|
|
17
|
+
* await applySocialAccountMigration(db, { registry })
|
|
18
|
+
* },
|
|
19
|
+
* async down(db) {
|
|
20
|
+
* await db.execute(emitDropTable(socialAccountSchema.name).sql)
|
|
21
|
+
* },
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
emitCreateTable,
|
|
28
|
+
type DatabaseExecutor,
|
|
29
|
+
type SchemaRegistry,
|
|
30
|
+
} from '@strav/database'
|
|
31
|
+
import { socialAccountSchema } from './social_account_schema.ts'
|
|
32
|
+
|
|
33
|
+
export interface ApplySocialAccountMigrationOptions {
|
|
34
|
+
/** Required for `emitCreateTable` to resolve relations. */
|
|
35
|
+
registry: SchemaRegistry
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function applySocialAccountMigration(
|
|
39
|
+
db: DatabaseExecutor,
|
|
40
|
+
options: ApplySocialAccountMigrationOptions,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const { registry } = options
|
|
43
|
+
|
|
44
|
+
await db.execute(emitCreateTable(socialAccountSchema, { registry }).sql)
|
|
45
|
+
|
|
46
|
+
// Provider-identity uniqueness — one Google / Line / Facebook
|
|
47
|
+
// identity belongs to exactly one user. The sign-in lookup
|
|
48
|
+
// (`findByProviderIdentity`) leans on this index.
|
|
49
|
+
await db.execute(
|
|
50
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_provider_identity"
|
|
51
|
+
ON "${socialAccountSchema.name}" ("provider", "provider_user_id")`,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// Per-user-per-provider uniqueness — a single user can only
|
|
55
|
+
// link one account per provider.
|
|
56
|
+
await db.execute(
|
|
57
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_user_provider"
|
|
58
|
+
ON "${socialAccountSchema.name}" ("user_id", "provider")`,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// "All accounts for user" lookup — account-settings UI.
|
|
62
|
+
await db.execute(
|
|
63
|
+
`CREATE INDEX IF NOT EXISTS "idx_social_account_user"
|
|
64
|
+
ON "${socialAccountSchema.name}" ("user_id")`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
applySocialAccountMigration,
|
|
3
|
+
type ApplySocialAccountMigrationOptions,
|
|
4
|
+
} from './apply_social_account_migration.ts'
|
|
5
|
+
export { SocialAccount } from './social_account.ts'
|
|
6
|
+
export { socialAccountSchema } from './social_account_schema.ts'
|
|
7
|
+
export {
|
|
8
|
+
type ConnectInput,
|
|
9
|
+
type DisconnectInput,
|
|
10
|
+
SocialAccountAlreadyLinkedError,
|
|
11
|
+
SocialAccountRepository,
|
|
12
|
+
} from './social_account_repository.ts'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialAccount` — typed row of the linked-provider ledger.
|
|
3
|
+
*
|
|
4
|
+
* `@encrypt` on the token fields tells the Repository to wrap
|
|
5
|
+
* them through the registered `EncryptionProvider` on
|
|
6
|
+
* write/read. In memory they are plain strings; on disk they
|
|
7
|
+
* are bytea.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { encrypt, Model } from '@strav/database'
|
|
11
|
+
import { socialAccountSchema } from './social_account_schema.ts'
|
|
12
|
+
|
|
13
|
+
export class SocialAccount extends Model {
|
|
14
|
+
static override readonly schema = socialAccountSchema
|
|
15
|
+
|
|
16
|
+
id!: string
|
|
17
|
+
user_id!: string
|
|
18
|
+
provider!: string
|
|
19
|
+
provider_user_id!: string
|
|
20
|
+
email!: string | null
|
|
21
|
+
name!: string | null
|
|
22
|
+
avatar_url!: string | null
|
|
23
|
+
locale!: string | null
|
|
24
|
+
@encrypt access_token!: string
|
|
25
|
+
@encrypt refresh_token!: string | null
|
|
26
|
+
@encrypt id_token!: string | null
|
|
27
|
+
expires_at!: Date | null
|
|
28
|
+
scope!: string | null
|
|
29
|
+
metadata!: Record<string, unknown>
|
|
30
|
+
created_at!: Date
|
|
31
|
+
updated_at!: Date
|
|
32
|
+
}
|