@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
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (Proof Key for Code Exchange) helpers — RFC 7636.
|
|
3
|
+
*
|
|
4
|
+
* For public clients (mobile, SPA, even server-side apps that
|
|
5
|
+
* can't keep a "client secret" truly secret), PKCE makes the
|
|
6
|
+
* authorization code worthless to an attacker who intercepts it
|
|
7
|
+
* mid-flight. Google requires PKCE on all new flows; Line supports
|
|
8
|
+
* it; Facebook ignores it.
|
|
9
|
+
*
|
|
10
|
+
* Flow:
|
|
11
|
+
*
|
|
12
|
+
* 1. `randomCodeVerifier()` → high-entropy random string.
|
|
13
|
+
* Apps store this against the user's session for the
|
|
14
|
+
* callback step.
|
|
15
|
+
* 2. `codeChallengeFor(verifier)` → base64url(sha256(verifier)).
|
|
16
|
+
* Drivers include this on the authorize URL as
|
|
17
|
+
* `code_challenge` + `code_challenge_method=S256`.
|
|
18
|
+
* 3. On callback, apps pass the stored verifier into
|
|
19
|
+
* `driver.exchange({...codeVerifier})`. The provider
|
|
20
|
+
* hashes it again and rejects the exchange if the hashes
|
|
21
|
+
* don't match.
|
|
22
|
+
*
|
|
23
|
+
* Plain (S256-only) implementation — we never emit `method=plain`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const VERIFIER_LENGTH = 64 // RFC allows 43–128; 64 is comfortably above floor.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cryptographically-strong random verifier (URL-safe alphabet).
|
|
30
|
+
* 64 chars at 6 bits/char ≈ 384 bits of entropy.
|
|
31
|
+
*/
|
|
32
|
+
export function randomCodeVerifier(): string {
|
|
33
|
+
const bytes = new Uint8Array(VERIFIER_LENGTH)
|
|
34
|
+
crypto.getRandomValues(bytes)
|
|
35
|
+
return base64UrlEncode(bytes).slice(0, VERIFIER_LENGTH)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** SHA-256 → base64url. The challenge the provider stores until callback. */
|
|
39
|
+
export async function codeChallengeFor(verifier: string): Promise<string> {
|
|
40
|
+
const buf = new TextEncoder().encode(verifier)
|
|
41
|
+
const hash = await crypto.subtle.digest('SHA-256', buf)
|
|
42
|
+
return base64UrlEncode(new Uint8Array(hash))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Random state — opaque CSRF token apps include on the
|
|
47
|
+
* authorize URL and verify on the callback. Drivers expose the
|
|
48
|
+
* verification helper (`assertStateMatches`) so apps don't
|
|
49
|
+
* have to roll the comparison themselves.
|
|
50
|
+
*/
|
|
51
|
+
export function randomState(): string {
|
|
52
|
+
const bytes = new Uint8Array(32)
|
|
53
|
+
crypto.getRandomValues(bytes)
|
|
54
|
+
return base64UrlEncode(bytes)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
58
|
+
// btoa needs a binary string; Bun + browsers + Node 18+ all
|
|
59
|
+
// handle this idiom identically.
|
|
60
|
+
let s = ''
|
|
61
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)
|
|
62
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialCapability` — feature flags every driver declares.
|
|
3
|
+
*
|
|
4
|
+
* Apps that build account-connect UI check these to gate buttons
|
|
5
|
+
* / scopes / refresh-token flows. Drivers omit a flag when they
|
|
6
|
+
* can't fulfil it faithfully — partial / surprising behaviour is
|
|
7
|
+
* worse than `ProviderUnsupportedError`.
|
|
8
|
+
*
|
|
9
|
+
* Granularity is intentionally fine: e.g. Facebook supports
|
|
10
|
+
* `tokens.refresh` only for long-lived tokens issued by the Pages
|
|
11
|
+
* API path, so v1 marks it unsupported; Line supports it
|
|
12
|
+
* uniformly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type SocialCapability =
|
|
16
|
+
// OIDC vs plain OAuth2
|
|
17
|
+
| 'openid' // returns an id_token + nonce flow
|
|
18
|
+
| 'pkce.support' // accepts PKCE (codeChallenge / codeVerifier)
|
|
19
|
+
| 'pkce.required' // mandates PKCE (Google)
|
|
20
|
+
// Profile data we can normalize
|
|
21
|
+
| 'profile.id'
|
|
22
|
+
| 'profile.email'
|
|
23
|
+
| 'profile.emailVerified'
|
|
24
|
+
| 'profile.name'
|
|
25
|
+
| 'profile.avatar'
|
|
26
|
+
| 'profile.locale'
|
|
27
|
+
// Token operations
|
|
28
|
+
| 'tokens.exchange'
|
|
29
|
+
| 'tokens.refresh'
|
|
30
|
+
| 'tokens.revoke'
|
|
31
|
+
| 'tokens.introspect'
|
|
32
|
+
// Scopes — each driver exposes its supported list separately
|
|
33
|
+
// via `driver.availableScopes`, but the flag here gates
|
|
34
|
+
// "scope picker UI" generation.
|
|
35
|
+
| 'scopes.discoverable'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialDriver` — the driver contract every adapter implements.
|
|
3
|
+
*
|
|
4
|
+
* One driver represents a configured provider instance
|
|
5
|
+
* (`config.social.providers[name]`). The manager holds one
|
|
6
|
+
* driver per configured name and routes calls into it.
|
|
7
|
+
*
|
|
8
|
+
* Methods drivers don't support throw `ProviderUnsupportedError`
|
|
9
|
+
* synchronously. The driver's `capabilities` set declares the
|
|
10
|
+
* supported feature set — apps that branch on capability avoid
|
|
11
|
+
* the throw by checking first.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OAuthTokens, SocialProfile } from './dto/index.ts'
|
|
15
|
+
import type { SocialCapability } from './social_capabilities.ts'
|
|
16
|
+
|
|
17
|
+
export interface AuthorizeInput {
|
|
18
|
+
/**
|
|
19
|
+
* Where the provider redirects after consent. Must match
|
|
20
|
+
* what's registered in the provider's developer console.
|
|
21
|
+
*/
|
|
22
|
+
redirectUri: string
|
|
23
|
+
/**
|
|
24
|
+
* OAuth scope list. Drivers expose `availableScopes` for
|
|
25
|
+
* apps that want to render a picker; apps usually hard-code
|
|
26
|
+
* `['profile', 'email']` or `['openid', 'profile', 'email']`.
|
|
27
|
+
*/
|
|
28
|
+
scopes?: readonly string[]
|
|
29
|
+
/**
|
|
30
|
+
* Override the CSRF state. When omitted, the driver generates
|
|
31
|
+
* one via `randomState()`. Apps that already have a
|
|
32
|
+
* session-bound nonce (and want to use it as state) pass it
|
|
33
|
+
* here.
|
|
34
|
+
*/
|
|
35
|
+
state?: string
|
|
36
|
+
/**
|
|
37
|
+
* Override the PKCE code verifier. When omitted AND the driver
|
|
38
|
+
* supports/requires PKCE, the driver generates one and returns
|
|
39
|
+
* it on the result. Apps store the returned verifier against
|
|
40
|
+
* the session for the callback step.
|
|
41
|
+
*/
|
|
42
|
+
codeVerifier?: string
|
|
43
|
+
/**
|
|
44
|
+
* Provider-specific extra query parameters (e.g. `prompt`,
|
|
45
|
+
* `access_type`, `bot_prompt` for Line). The driver merges
|
|
46
|
+
* these into the authorize URL after the standard params.
|
|
47
|
+
*/
|
|
48
|
+
extra?: Record<string, string>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AuthorizeResult {
|
|
52
|
+
/** The full URL the app redirects the customer to. */
|
|
53
|
+
url: string
|
|
54
|
+
state: string
|
|
55
|
+
/** Set when the driver issued / accepted a PKCE verifier. Apps persist this against the session. */
|
|
56
|
+
codeVerifier?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ExchangeInput {
|
|
60
|
+
code: string
|
|
61
|
+
redirectUri: string
|
|
62
|
+
/** Pass the state value the app stored at authorize-time. The driver verifies it matches `expectedState` (which the app provides on the callback). */
|
|
63
|
+
state?: string
|
|
64
|
+
/** Expected state — `state` from the AuthorizeResult the app stored. */
|
|
65
|
+
expectedState?: string
|
|
66
|
+
/** PKCE verifier — required by drivers that declared `pkce.required`. */
|
|
67
|
+
codeVerifier?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RefreshInput {
|
|
71
|
+
refreshToken: string
|
|
72
|
+
/** Optional narrowed scope list. Most providers ignore this. */
|
|
73
|
+
scopes?: readonly string[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SocialDriver {
|
|
77
|
+
/** Driver identifier — `'line'` / `'google'` / `'facebook'`. */
|
|
78
|
+
readonly name: string
|
|
79
|
+
/** App-chosen instance name (`config.social.providers[name]`). */
|
|
80
|
+
readonly instanceName: string
|
|
81
|
+
readonly capabilities: ReadonlySet<SocialCapability>
|
|
82
|
+
/** Provider-supported scope list. Apps render pickers from this; drivers reject unknown scopes at authorize time. */
|
|
83
|
+
readonly availableScopes: readonly string[]
|
|
84
|
+
|
|
85
|
+
/** Build the authorize URL + emit state / PKCE artefacts the app stores. */
|
|
86
|
+
authorize(input: AuthorizeInput): Promise<AuthorizeResult>
|
|
87
|
+
|
|
88
|
+
/** Exchange the callback code for tokens. Verifies state when both `state` and `expectedState` are provided. */
|
|
89
|
+
exchange(input: ExchangeInput): Promise<OAuthTokens>
|
|
90
|
+
|
|
91
|
+
/** Fetch the normalized user profile using a valid access token. */
|
|
92
|
+
profile(accessToken: string): Promise<SocialProfile>
|
|
93
|
+
|
|
94
|
+
/** Trade a refresh token for a fresh access token. Drivers without the `tokens.refresh` capability throw. */
|
|
95
|
+
refresh(input: RefreshInput): Promise<OAuthTokens>
|
|
96
|
+
|
|
97
|
+
/** Revoke a token. Drivers without `tokens.revoke` throw. */
|
|
98
|
+
revoke(token: string): Promise<void>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Factory the manager invokes per configured provider. */
|
|
102
|
+
export type SocialDriverFactory = (config: {
|
|
103
|
+
instanceName: string
|
|
104
|
+
config: Record<string, unknown> & { driver: string }
|
|
105
|
+
}) => SocialDriver
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialError` hierarchy — typed wrappers for OAuth/OIDC
|
|
3
|
+
* failures. Provider-native errors are preserved on `.cause`
|
|
4
|
+
* so apps can `instanceof` the vendor exception for retry /
|
|
5
|
+
* recovery logic; the wrapper gives the framework a consistent
|
|
6
|
+
* `StravError` to render through the standard exception handler.
|
|
7
|
+
*
|
|
8
|
+
* Subclasses:
|
|
9
|
+
*
|
|
10
|
+
* - `SocialConfigError` — boot-time misconfiguration
|
|
11
|
+
* (missing client id / secret / redirect uri).
|
|
12
|
+
*
|
|
13
|
+
* - `UnknownProviderError` — `social.use(name)` for a name
|
|
14
|
+
* not configured.
|
|
15
|
+
*
|
|
16
|
+
* - `ProviderUnsupportedError` — driver doesn't implement the
|
|
17
|
+
* requested operation (Facebook driver lacks
|
|
18
|
+
* `tokens.refresh` for example).
|
|
19
|
+
*
|
|
20
|
+
* - `StateMismatchError` — `state` returned on the callback
|
|
21
|
+
* doesn't match what `authorize()` issued. Strong signal
|
|
22
|
+
* of CSRF or a misrouted callback.
|
|
23
|
+
*
|
|
24
|
+
* - `OAuthExchangeError` — provider rejected the authorization
|
|
25
|
+
* code (expired, already used, wrong client).
|
|
26
|
+
*
|
|
27
|
+
* - `InvalidTokenError` — provider rejected the access /
|
|
28
|
+
* refresh token (expired, revoked, scope-mismatched).
|
|
29
|
+
*
|
|
30
|
+
* - `SocialProviderError` — generic wrapper for vendor
|
|
31
|
+
* exceptions that don't map to a more specific subclass.
|
|
32
|
+
* `cause` preserved.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { StravError } from '@strav/kernel'
|
|
36
|
+
|
|
37
|
+
export class SocialError extends StravError {
|
|
38
|
+
constructor(
|
|
39
|
+
message: string,
|
|
40
|
+
options: {
|
|
41
|
+
code?: string
|
|
42
|
+
status?: number
|
|
43
|
+
context?: Record<string, unknown>
|
|
44
|
+
cause?: unknown
|
|
45
|
+
} = {},
|
|
46
|
+
) {
|
|
47
|
+
super(
|
|
48
|
+
message,
|
|
49
|
+
{ code: options.code ?? 'social.error', status: options.status ?? 500 },
|
|
50
|
+
{
|
|
51
|
+
...(options.context ? { context: options.context } : {}),
|
|
52
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SocialConfigError extends SocialError {
|
|
59
|
+
constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
|
|
60
|
+
super(message, {
|
|
61
|
+
code: 'social.config',
|
|
62
|
+
status: 500,
|
|
63
|
+
...(options.context ? { context: options.context } : {}),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class UnknownProviderError extends SocialError {
|
|
69
|
+
constructor(name: string, available: readonly string[]) {
|
|
70
|
+
super(
|
|
71
|
+
`Social provider "${name}" is not configured. Available: ${available.join(', ') || '<none>'}.`,
|
|
72
|
+
{
|
|
73
|
+
code: 'social.unknown_provider',
|
|
74
|
+
status: 400,
|
|
75
|
+
context: { requested: name, available },
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ProviderUnsupportedError extends SocialError {
|
|
82
|
+
constructor(provider: string, operation: string, options: { reason?: string } = {}) {
|
|
83
|
+
const trailer = options.reason ? ` ${options.reason}` : ''
|
|
84
|
+
super(
|
|
85
|
+
`Social provider "${provider}" does not support "${operation}".${trailer}`,
|
|
86
|
+
{
|
|
87
|
+
code: 'social.provider_unsupported',
|
|
88
|
+
status: 400,
|
|
89
|
+
context: {
|
|
90
|
+
provider,
|
|
91
|
+
operation,
|
|
92
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class StateMismatchError extends SocialError {
|
|
100
|
+
constructor(message = 'Social OAuth callback state mismatch — possible CSRF or misrouted callback.') {
|
|
101
|
+
super(message, { code: 'social.state_mismatch', status: 400 })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class OAuthExchangeError extends SocialError {
|
|
106
|
+
constructor(
|
|
107
|
+
message: string,
|
|
108
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
109
|
+
) {
|
|
110
|
+
super(message, {
|
|
111
|
+
code: 'social.oauth_exchange',
|
|
112
|
+
status: 400,
|
|
113
|
+
...(options.context ? { context: options.context } : {}),
|
|
114
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class InvalidTokenError extends SocialError {
|
|
120
|
+
constructor(
|
|
121
|
+
message: string,
|
|
122
|
+
options: { context?: Record<string, unknown>; cause?: unknown } = {},
|
|
123
|
+
) {
|
|
124
|
+
super(message, {
|
|
125
|
+
code: 'social.invalid_token',
|
|
126
|
+
status: 401,
|
|
127
|
+
...(options.context ? { context: options.context } : {}),
|
|
128
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class SocialProviderError extends SocialError {
|
|
134
|
+
constructor(
|
|
135
|
+
message: string,
|
|
136
|
+
options: {
|
|
137
|
+
provider: string
|
|
138
|
+
operation: string
|
|
139
|
+
context?: Record<string, unknown>
|
|
140
|
+
cause?: unknown
|
|
141
|
+
status?: number
|
|
142
|
+
},
|
|
143
|
+
) {
|
|
144
|
+
super(message, {
|
|
145
|
+
code: 'social.provider_error',
|
|
146
|
+
status: options.status ?? 502,
|
|
147
|
+
context: {
|
|
148
|
+
provider: options.provider,
|
|
149
|
+
operation: options.operation,
|
|
150
|
+
...(options.context ?? {}),
|
|
151
|
+
},
|
|
152
|
+
...(options.cause !== undefined ? { cause: options.cause } : {}),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/social_manager.ts
CHANGED
|
@@ -1,98 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `SocialManager` — the facade apps use for social-login flows.
|
|
3
|
+
*
|
|
4
|
+
* Three concept clusters:
|
|
5
|
+
*
|
|
6
|
+
* - **Drivers.** Apps declare providers in
|
|
7
|
+
* `config.social.providers`. The manager constructs each
|
|
8
|
+
* driver lazily on first `use(name)` call + memoizes.
|
|
9
|
+
* Custom drivers register via `manager.extend(name, factory)`.
|
|
10
|
+
*
|
|
11
|
+
* - **Resource accessors** (`authorize`, `exchange`, `profile`,
|
|
12
|
+
* `refresh`, `revoke`) — route to the default driver. Apps
|
|
13
|
+
* that route by region call `social.use('asia').authorize(...)`.
|
|
14
|
+
*
|
|
15
|
+
* - **Capabilities.** `driver.capabilities` exposes the
|
|
16
|
+
* feature set — apps that build provider-aware UI (e.g.
|
|
17
|
+
* "only show refresh button if the driver supports it")
|
|
18
|
+
* read from there.
|
|
19
|
+
*/
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
import type {
|
|
22
|
+
AuthorizeInput,
|
|
23
|
+
AuthorizeResult,
|
|
24
|
+
ExchangeInput,
|
|
25
|
+
RefreshInput,
|
|
26
|
+
SocialDriver,
|
|
27
|
+
SocialDriverFactory,
|
|
28
|
+
} from './social_driver.ts'
|
|
29
|
+
import type { OAuthTokens, SocialProfile } from './dto/index.ts'
|
|
30
|
+
import {
|
|
31
|
+
SocialConfigError,
|
|
32
|
+
UnknownProviderError,
|
|
33
|
+
} from './social_error.ts'
|
|
34
|
+
import type { ProviderConfig, SocialConfig } from './types.ts'
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
export interface SocialManagerOptions {
|
|
37
|
+
config: SocialConfig
|
|
38
|
+
}
|
|
21
39
|
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
export class SocialManager {
|
|
41
|
+
readonly config: SocialConfig
|
|
42
|
+
private readonly drivers = new Map<string, SocialDriver>()
|
|
43
|
+
private readonly extensions = new Map<string, SocialDriverFactory>()
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
constructor(options: SocialManagerOptions) {
|
|
46
|
+
const { config } = options
|
|
47
|
+
if (!config.providers[config.default]) {
|
|
48
|
+
throw new SocialConfigError(
|
|
49
|
+
`SocialManager: default provider "${config.default}" is not configured.`,
|
|
50
|
+
{
|
|
51
|
+
context: {
|
|
52
|
+
default: config.default,
|
|
53
|
+
available: Object.keys(config.providers),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
)
|
|
28
57
|
}
|
|
58
|
+
this.config = config
|
|
29
59
|
}
|
|
30
60
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
use(name?: string): SocialDriver {
|
|
62
|
+
const key = name ?? this.config.default
|
|
63
|
+
const cached = this.drivers.get(key)
|
|
64
|
+
if (cached) return cached
|
|
65
|
+
|
|
66
|
+
const cfg = this.config.providers[key]
|
|
67
|
+
if (!cfg) {
|
|
68
|
+
throw new UnknownProviderError(key, Object.keys(this.config.providers))
|
|
69
|
+
}
|
|
70
|
+
const ext = this.extensions.get(cfg.driver)
|
|
71
|
+
if (!ext) {
|
|
72
|
+
throw new SocialConfigError(
|
|
73
|
+
`SocialManager: unknown driver "${cfg.driver}" for provider "${key}". Register it via \`manager.extend("${cfg.driver}", factory)\` or install the matching adapter package.`,
|
|
74
|
+
{ context: { driver: cfg.driver, available: [...this.extensions.keys()] } },
|
|
35
75
|
)
|
|
36
76
|
}
|
|
37
|
-
|
|
77
|
+
const driver = ext({
|
|
78
|
+
instanceName: key,
|
|
79
|
+
config: cfg as ProviderConfig & { driver: string },
|
|
80
|
+
})
|
|
81
|
+
this.drivers.set(key, driver)
|
|
82
|
+
return driver
|
|
38
83
|
}
|
|
39
84
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'SocialManager not configured. Resolve it through the container first.'
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
return SocialManager._config
|
|
85
|
+
/** Register a driver factory. Adapter packages call this from their ServiceProvider. */
|
|
86
|
+
extend(driverName: string, factory: SocialDriverFactory): void {
|
|
87
|
+
this.extensions.set(driverName, factory)
|
|
47
88
|
}
|
|
48
89
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
90
|
+
/** Hand-wire a driver instance (tests / one-offs). */
|
|
91
|
+
useDriver(instanceName: string, driver: SocialDriver): void {
|
|
92
|
+
this.drivers.set(instanceName, driver)
|
|
52
93
|
}
|
|
53
94
|
|
|
54
|
-
|
|
55
|
-
* Get a fresh provider instance by name.
|
|
56
|
-
* Returns a new instance each call because fluent methods mutate state.
|
|
57
|
-
*/
|
|
58
|
-
static driver(name: string): AbstractProvider {
|
|
59
|
-
const providerConfig = SocialManager._config?.providers[name]
|
|
60
|
-
if (!providerConfig) {
|
|
61
|
-
throw new ConfigurationError(`Social provider "${name}" is not configured.`)
|
|
62
|
-
}
|
|
95
|
+
// ─── Resource accessors (route to the default driver) ────────────────
|
|
63
96
|
|
|
64
|
-
|
|
97
|
+
authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
98
|
+
return this.use().authorize(input)
|
|
99
|
+
}
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
102
|
+
return this.use().exchange(input)
|
|
103
|
+
}
|
|
68
104
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return new GoogleProvider(providerConfig)
|
|
72
|
-
case 'github':
|
|
73
|
-
return new GitHubProvider(providerConfig)
|
|
74
|
-
case 'discord':
|
|
75
|
-
return new DiscordProvider(providerConfig)
|
|
76
|
-
case 'facebook':
|
|
77
|
-
return new FacebookProvider(providerConfig)
|
|
78
|
-
case 'linkedin':
|
|
79
|
-
return new LinkedInProvider(providerConfig)
|
|
80
|
-
case 'line':
|
|
81
|
-
return new LineProvider(providerConfig)
|
|
82
|
-
default:
|
|
83
|
-
throw new ConfigurationError(
|
|
84
|
-
`Unknown social driver "${driverName}". Register it with SocialManager.extend().`
|
|
85
|
-
)
|
|
86
|
-
}
|
|
105
|
+
profile(accessToken: string): Promise<SocialProfile> {
|
|
106
|
+
return this.use().profile(accessToken)
|
|
87
107
|
}
|
|
88
108
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
SocialManager._extensions.set(name, factory)
|
|
109
|
+
refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
110
|
+
return this.use().refresh(input)
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
SocialManager._extensions.clear()
|
|
113
|
+
revoke(token: string): Promise<void> {
|
|
114
|
+
return this.use().revoke(token)
|
|
97
115
|
}
|
|
98
116
|
}
|
package/src/social_provider.ts
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `SocialProvider` — `ServiceProvider` that wires
|
|
3
|
+
* `SocialManager` into the container from `config.social`.
|
|
4
|
+
*
|
|
5
|
+
* Adapter packages register their driver factories via their
|
|
6
|
+
* own ServiceProvider (e.g. `LineSocialProvider`) listed AFTER
|
|
7
|
+
* this one in `bootstrap/providers.ts`. The adapter's
|
|
8
|
+
* `register()` calls `manager.extend('line', factory)`; this
|
|
9
|
+
* provider's `boot()` eagerly resolves the manager so config
|
|
10
|
+
* errors surface at boot, not on first call.
|
|
11
|
+
*
|
|
12
|
+
* Driver instances are constructed lazily on first
|
|
13
|
+
* `social.use(name)` call.
|
|
14
|
+
*/
|
|
4
15
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
import {
|
|
17
|
+
type Application,
|
|
18
|
+
ConfigRepository,
|
|
19
|
+
ServiceProvider,
|
|
20
|
+
} from '@strav/kernel'
|
|
21
|
+
import { SocialConfigError } from './social_error.ts'
|
|
22
|
+
import { SocialManager } from './social_manager.ts'
|
|
23
|
+
import type { SocialConfig } from './types.ts'
|
|
24
|
+
|
|
25
|
+
export class SocialProvider extends ServiceProvider {
|
|
26
|
+
override readonly name = 'social'
|
|
27
|
+
override readonly dependencies = ['config']
|
|
8
28
|
|
|
9
29
|
override register(app: Application): void {
|
|
10
|
-
app.singleton(SocialManager)
|
|
30
|
+
app.singleton(SocialManager, (c) => {
|
|
31
|
+
const raw = c.resolve(ConfigRepository).get('social') as SocialConfig | undefined
|
|
32
|
+
if (!raw) {
|
|
33
|
+
throw new SocialConfigError(
|
|
34
|
+
'SocialProvider: `config.social` is missing. Add `config/social.ts` with at least one provider.',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
if (!raw.providers || Object.keys(raw.providers).length === 0) {
|
|
38
|
+
throw new SocialConfigError(
|
|
39
|
+
'SocialProvider: `config.social.providers` is empty. Configure at least one provider.',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return new SocialManager({ config: raw })
|
|
43
|
+
})
|
|
11
44
|
}
|
|
12
45
|
|
|
13
46
|
override boot(app: Application): void {
|
|
47
|
+
// Force-resolve so config errors surface at boot, not on first call.
|
|
14
48
|
app.resolve(SocialManager)
|
|
15
49
|
}
|
|
16
50
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `applyTenantedSocialAccountMigration` — DDL for the opt-in
|
|
3
|
+
* tenanted variant of the social-account ledger.
|
|
4
|
+
*
|
|
5
|
+
* Composite unique becomes `(tenant_id, provider,
|
|
6
|
+
* provider_user_id)` — the same Google account can be linked
|
|
7
|
+
* once per tenant. The `user_id` index serves the "all
|
|
8
|
+
* accounts for user" lookup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
emitCreateTable,
|
|
13
|
+
type DatabaseExecutor,
|
|
14
|
+
type SchemaRegistry,
|
|
15
|
+
} from '@strav/database'
|
|
16
|
+
import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
|
|
17
|
+
|
|
18
|
+
export interface ApplyTenantedSocialAccountMigrationOptions {
|
|
19
|
+
/** Required for `emitCreateTable` to resolve the tenant FK ref. */
|
|
20
|
+
registry: SchemaRegistry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function applyTenantedSocialAccountMigration(
|
|
24
|
+
db: DatabaseExecutor,
|
|
25
|
+
options: ApplyTenantedSocialAccountMigrationOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const { registry } = options
|
|
28
|
+
|
|
29
|
+
await db.execute(emitCreateTable(tenantedSocialAccountSchema, { registry }).sql)
|
|
30
|
+
|
|
31
|
+
await db.execute(
|
|
32
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_tenant_identity"
|
|
33
|
+
ON "${tenantedSocialAccountSchema.name}" ("tenant_id", "provider", "provider_user_id")`,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
await db.execute(
|
|
37
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_social_account_user_provider"
|
|
38
|
+
ON "${tenantedSocialAccountSchema.name}" ("user_id", "provider")`,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
await db.execute(
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS "idx_social_account_user"
|
|
43
|
+
ON "${tenantedSocialAccountSchema.name}" ("user_id")`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Public API of `@strav/social/tenanted` — the opt-in
|
|
2
|
+
// tenant-scoped variant of the social-account ledger.
|
|
3
|
+
//
|
|
4
|
+
// Apps that need per-tenant social accounts import from here.
|
|
5
|
+
// Default single-tenant apps stay on `@strav/social` and never
|
|
6
|
+
// pay for the extra column / RLS / `withTenant` wrapping.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
applyTenantedSocialAccountMigration,
|
|
10
|
+
type ApplyTenantedSocialAccountMigrationOptions,
|
|
11
|
+
} from './apply_tenanted_social_account_migration.ts'
|
|
12
|
+
export { TenantedSocialAccount } from './tenanted_social_account.ts'
|
|
13
|
+
export {
|
|
14
|
+
type ConnectInput,
|
|
15
|
+
type DisconnectInput,
|
|
16
|
+
TenantedSocialAccountRepository,
|
|
17
|
+
} from './tenanted_social_account_repository.ts'
|
|
18
|
+
export { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
|