@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
|
@@ -1,69 +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
|
-
const API_VERSION = 'v21.0'
|
|
6
|
-
|
|
7
|
-
export class FacebookProvider extends AbstractProvider {
|
|
8
|
-
readonly name = 'Facebook'
|
|
9
|
-
|
|
10
|
-
protected getDefaultScopes(): string[] {
|
|
11
|
-
return ['email', 'public_profile']
|
|
12
|
-
}
|
|
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
|
-
|
|
24
|
-
protected getAuthUrl(): string {
|
|
25
|
-
return `https://www.facebook.com/${API_VERSION}/dialog/oauth`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
protected getTokenUrl(): string {
|
|
29
|
-
return `https://graph.facebook.com/${API_VERSION}/oauth/access_token`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
33
|
-
const fields = 'id,name,email,picture.type(large)'
|
|
34
|
-
const response = await fetch(
|
|
35
|
-
`https://graph.facebook.com/${API_VERSION}/me?fields=${fields}&access_token=${token}`
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
if (!response.ok) {
|
|
39
|
-
throw new ExternalServiceError(
|
|
40
|
-
'Facebook',
|
|
41
|
-
response.status,
|
|
42
|
-
scrubProviderError(await response.text())
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (await response.json()) as Record<string, unknown>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
50
|
-
const picture = data.picture as { data?: { url?: string } } | undefined
|
|
51
|
-
const email = (data.email as string) ?? null
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
id: data.id as string,
|
|
55
|
-
name: (data.name 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,
|
|
60
|
-
avatar: picture?.data?.url ?? null,
|
|
61
|
-
nickname: null,
|
|
62
|
-
token: '',
|
|
63
|
-
refreshToken: null,
|
|
64
|
-
expiresIn: null,
|
|
65
|
-
approvedScopes: [],
|
|
66
|
-
raw: data,
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
@@ -1,75 +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 GitHubProvider extends AbstractProvider {
|
|
6
|
-
readonly name = 'GitHub'
|
|
7
|
-
|
|
8
|
-
protected getDefaultScopes(): string[] {
|
|
9
|
-
return ['read:user', 'user:email']
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
protected getAuthUrl(): string {
|
|
13
|
-
return 'https://github.com/login/oauth/authorize'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected getTokenUrl(): string {
|
|
17
|
-
return 'https://github.com/login/oauth/access_token'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
-
const headers = {
|
|
22
|
-
Authorization: `Bearer ${token}`,
|
|
23
|
-
Accept: 'application/json',
|
|
24
|
-
'User-Agent': 'Strav-Social',
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const [userResponse, emailsResponse] = await Promise.all([
|
|
28
|
-
fetch('https://api.github.com/user', { headers }),
|
|
29
|
-
fetch('https://api.github.com/user/emails', { headers }),
|
|
30
|
-
])
|
|
31
|
-
|
|
32
|
-
if (!userResponse.ok) {
|
|
33
|
-
throw new ExternalServiceError(
|
|
34
|
-
'GitHub',
|
|
35
|
-
userResponse.status,
|
|
36
|
-
scrubProviderError(await userResponse.text())
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const user = (await userResponse.json()) as Record<string, unknown>
|
|
41
|
-
|
|
42
|
-
// If the user's profile email is private, fall back to the primary verified email
|
|
43
|
-
if (!user.email && emailsResponse.ok) {
|
|
44
|
-
const emails = (await emailsResponse.json()) as Array<{
|
|
45
|
-
email: string
|
|
46
|
-
primary: boolean
|
|
47
|
-
verified: boolean
|
|
48
|
-
}>
|
|
49
|
-
const primary = emails.find(e => e.primary && e.verified)
|
|
50
|
-
if (primary) user.email = primary.email
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return user
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
57
|
-
const email = (data.email as string) ?? null
|
|
58
|
-
return {
|
|
59
|
-
id: String(data.id),
|
|
60
|
-
name: (data.name 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,
|
|
66
|
-
avatar: (data.avatar_url as string) ?? null,
|
|
67
|
-
nickname: (data.login as string) ?? null,
|
|
68
|
-
token: '',
|
|
69
|
-
refreshToken: null,
|
|
70
|
-
expiresIn: null,
|
|
71
|
-
approvedScopes: [],
|
|
72
|
-
raw: data,
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -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 GoogleProvider extends AbstractProvider {
|
|
6
|
-
readonly name = 'Google'
|
|
7
|
-
|
|
8
|
-
protected getDefaultScopes(): string[] {
|
|
9
|
-
return ['openid', 'email', 'profile']
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
protected getAuthUrl(): string {
|
|
13
|
-
return 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected getTokenUrl(): string {
|
|
17
|
-
return 'https://oauth2.googleapis.com/token'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
-
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
|
22
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
throw new ExternalServiceError(
|
|
27
|
-
'Google',
|
|
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
|
-
}
|
|
@@ -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
|
-
})
|
package/src/social_account.ts
DELETED
|
@@ -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
|
-
}
|
package/stubs/config/social.ts
DELETED
|
@@ -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
|
-
})
|