@strav/social 0.4.31 → 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/package.json
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/social",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.22",
|
|
4
|
+
"description": "Strav social-login module — provider-agnostic OAuth/OIDC client. Normalized profile + token DTOs, state + PKCE helpers, capability gating, multi-provider routing. Line / Google / Facebook adapters ship as subpath imports (`@strav/social/line`, `@strav/social/google`, `@strav/social/facebook`).",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": "./src/index.ts",
|
|
9
|
-
"
|
|
10
|
+
"./line": "./src/line/index.ts",
|
|
11
|
+
"./google": "./src/google/index.ts",
|
|
12
|
+
"./facebook": "./src/facebook/index.ts",
|
|
13
|
+
"./tenanted": "./src/tenanted/index.ts"
|
|
10
14
|
},
|
|
11
15
|
"files": [
|
|
12
|
-
"src
|
|
13
|
-
"
|
|
14
|
-
"package.json",
|
|
15
|
-
"tsconfig.json",
|
|
16
|
-
"CHANGELOG.md"
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
17
18
|
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"bun": ">=1.3.14"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@strav/database": "1.0.0-alpha.22",
|
|
27
|
+
"@strav/http": "1.0.0-alpha.22",
|
|
28
|
+
"@strav/kernel": "1.0.0-alpha.22"
|
|
29
|
+
},
|
|
18
30
|
"peerDependencies": {
|
|
19
|
-
"@
|
|
20
|
-
"@strav/http": "0.4.31",
|
|
21
|
-
"@strav/database": "0.4.31"
|
|
31
|
+
"@types/bun": ">=1.3.14"
|
|
22
32
|
},
|
|
23
|
-
"
|
|
24
|
-
"test": "bun test tests/",
|
|
25
|
-
"typecheck": "tsc --noEmit"
|
|
26
|
-
}
|
|
33
|
+
"devDependencies": null
|
|
27
34
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MockDriver` — in-memory reference implementation. Used by
|
|
3
|
+
* unit tests + as the canonical contract example for new
|
|
4
|
+
* adapters.
|
|
5
|
+
*
|
|
6
|
+
* Round-trips tokens + profiles through plain Maps. PKCE +
|
|
7
|
+
* state verification mirror what real drivers enforce so apps
|
|
8
|
+
* exercising the full flow against the mock catch bugs in
|
|
9
|
+
* their state handling before they hit a real provider.
|
|
10
|
+
*
|
|
11
|
+
* Capabilities: full by default — every flag declared. Tests
|
|
12
|
+
* that exercise `ProviderUnsupportedError` paths construct the
|
|
13
|
+
* mock with a narrowed `capabilities` set.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ulid } from '@strav/kernel'
|
|
17
|
+
import {
|
|
18
|
+
InvalidTokenError,
|
|
19
|
+
OAuthExchangeError,
|
|
20
|
+
StateMismatchError,
|
|
21
|
+
} from '../social_error.ts'
|
|
22
|
+
import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
|
|
23
|
+
import type { SocialCapability } from '../social_capabilities.ts'
|
|
24
|
+
import { codeChallengeFor, randomCodeVerifier, randomState } from '../pkce.ts'
|
|
25
|
+
import type {
|
|
26
|
+
AuthorizeInput,
|
|
27
|
+
AuthorizeResult,
|
|
28
|
+
ExchangeInput,
|
|
29
|
+
RefreshInput,
|
|
30
|
+
SocialDriver,
|
|
31
|
+
} from '../social_driver.ts'
|
|
32
|
+
|
|
33
|
+
const ALL_CAPS: readonly SocialCapability[] = [
|
|
34
|
+
'openid', 'pkce.support',
|
|
35
|
+
'profile.id', 'profile.email', 'profile.emailVerified',
|
|
36
|
+
'profile.name', 'profile.avatar', 'profile.locale',
|
|
37
|
+
'tokens.exchange', 'tokens.refresh', 'tokens.revoke', 'tokens.introspect',
|
|
38
|
+
'scopes.discoverable',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export interface MockDriverOptions {
|
|
42
|
+
instanceName?: string
|
|
43
|
+
capabilities?: ReadonlySet<SocialCapability>
|
|
44
|
+
/** Profile returned by `profile(accessToken)` calls. Tests override to assert specific shapes. */
|
|
45
|
+
profileFor?(accessToken: string): SocialProfile
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PendingFlow {
|
|
49
|
+
state: string
|
|
50
|
+
codeVerifier?: string
|
|
51
|
+
scopes: readonly string[]
|
|
52
|
+
redirectUri: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class MockDriver implements SocialDriver {
|
|
56
|
+
readonly name = 'mock'
|
|
57
|
+
readonly instanceName: string
|
|
58
|
+
readonly capabilities: ReadonlySet<SocialCapability>
|
|
59
|
+
readonly availableScopes: readonly string[] = ['openid', 'profile', 'email']
|
|
60
|
+
|
|
61
|
+
private readonly pending = new Map<string, PendingFlow>()
|
|
62
|
+
private readonly issuedTokens = new Map<string, { code: string; refreshToken: string }>()
|
|
63
|
+
private readonly profileForFn: (accessToken: string) => SocialProfile
|
|
64
|
+
|
|
65
|
+
constructor(options: MockDriverOptions = {}) {
|
|
66
|
+
this.instanceName = options.instanceName ?? 'mock'
|
|
67
|
+
this.capabilities = options.capabilities ?? new Set(ALL_CAPS)
|
|
68
|
+
this.profileForFn =
|
|
69
|
+
options.profileFor ??
|
|
70
|
+
((token: string): SocialProfile => ({
|
|
71
|
+
id: `mock_${token.slice(0, 8)}`,
|
|
72
|
+
provider: this.name,
|
|
73
|
+
email: `${token.slice(0, 6)}@mock.test`,
|
|
74
|
+
emailVerified: true,
|
|
75
|
+
name: 'Mock User',
|
|
76
|
+
avatarUrl: 'https://mock.test/avatar.png',
|
|
77
|
+
locale: 'en',
|
|
78
|
+
metadata: {},
|
|
79
|
+
raw: { mock: true },
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
|
|
84
|
+
const state = input.state ?? randomState()
|
|
85
|
+
const codeVerifier =
|
|
86
|
+
input.codeVerifier ??
|
|
87
|
+
(this.capabilities.has('pkce.support') ? randomCodeVerifier() : undefined)
|
|
88
|
+
const challenge = codeVerifier ? await codeChallengeFor(codeVerifier) : undefined
|
|
89
|
+
this.pending.set(state, {
|
|
90
|
+
state,
|
|
91
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
92
|
+
scopes: input.scopes ?? ['profile'],
|
|
93
|
+
redirectUri: input.redirectUri,
|
|
94
|
+
})
|
|
95
|
+
const params = new URLSearchParams({
|
|
96
|
+
response_type: 'code',
|
|
97
|
+
client_id: 'mock_client',
|
|
98
|
+
redirect_uri: input.redirectUri,
|
|
99
|
+
scope: (input.scopes ?? ['profile']).join(' '),
|
|
100
|
+
state,
|
|
101
|
+
...(challenge ? { code_challenge: challenge, code_challenge_method: 'S256' } : {}),
|
|
102
|
+
...(input.extra ?? {}),
|
|
103
|
+
})
|
|
104
|
+
return {
|
|
105
|
+
url: `https://mock.test/oauth/authorize?${params.toString()}`,
|
|
106
|
+
state,
|
|
107
|
+
...(codeVerifier ? { codeVerifier } : {}),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async exchange(input: ExchangeInput): Promise<OAuthTokens> {
|
|
112
|
+
if (input.expectedState !== undefined && input.state !== input.expectedState) {
|
|
113
|
+
throw new StateMismatchError()
|
|
114
|
+
}
|
|
115
|
+
const flow = input.state ? this.pending.get(input.state) : undefined
|
|
116
|
+
if (!flow) {
|
|
117
|
+
throw new OAuthExchangeError('MockDriver: no pending authorize for this state.')
|
|
118
|
+
}
|
|
119
|
+
if (flow.codeVerifier && flow.codeVerifier !== input.codeVerifier) {
|
|
120
|
+
throw new OAuthExchangeError('MockDriver: PKCE verifier mismatch.')
|
|
121
|
+
}
|
|
122
|
+
this.pending.delete(input.state!)
|
|
123
|
+
const accessToken = `mock_at_${ulid()}`
|
|
124
|
+
const refreshToken = `mock_rt_${ulid()}`
|
|
125
|
+
this.issuedTokens.set(accessToken, { code: input.code, refreshToken })
|
|
126
|
+
return {
|
|
127
|
+
accessToken,
|
|
128
|
+
refreshToken,
|
|
129
|
+
...(flow.scopes.includes('openid') ? { idToken: `mock_id_${ulid()}` } : {}),
|
|
130
|
+
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
131
|
+
scope: flow.scopes.join(' '),
|
|
132
|
+
tokenType: 'Bearer',
|
|
133
|
+
raw: { mock: true, code: input.code },
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async profile(accessToken: string): Promise<SocialProfile> {
|
|
138
|
+
if (!this.issuedTokens.has(accessToken)) {
|
|
139
|
+
throw new InvalidTokenError(`MockDriver.profile: unknown access token.`)
|
|
140
|
+
}
|
|
141
|
+
return this.profileForFn(accessToken)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async refresh(input: RefreshInput): Promise<OAuthTokens> {
|
|
145
|
+
// Find the access token whose refresh token matches.
|
|
146
|
+
const found = [...this.issuedTokens.entries()].find(
|
|
147
|
+
([, v]) => v.refreshToken === input.refreshToken,
|
|
148
|
+
)
|
|
149
|
+
if (!found) {
|
|
150
|
+
throw new InvalidTokenError('MockDriver.refresh: unknown refresh token.')
|
|
151
|
+
}
|
|
152
|
+
// Rotate.
|
|
153
|
+
this.issuedTokens.delete(found[0])
|
|
154
|
+
const accessToken = `mock_at_${ulid()}`
|
|
155
|
+
const newRefresh = `mock_rt_${ulid()}`
|
|
156
|
+
this.issuedTokens.set(accessToken, { code: found[1].code, refreshToken: newRefresh })
|
|
157
|
+
return {
|
|
158
|
+
accessToken,
|
|
159
|
+
refreshToken: newRefresh,
|
|
160
|
+
expiresAt: new Date(Date.now() + 3600 * 1000),
|
|
161
|
+
scope: (input.scopes ?? []).join(' '),
|
|
162
|
+
tokenType: 'Bearer',
|
|
163
|
+
raw: { mock: true },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async revoke(token: string): Promise<void> {
|
|
168
|
+
this.issuedTokens.delete(token)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unsupported(provider, operation, reason?)` — drivers stub
|
|
3
|
+
* out methods they can't fulfil. Returns a function that
|
|
4
|
+
* throws `ProviderUnsupportedError` synchronously.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ProviderUnsupportedError } from '../social_error.ts'
|
|
8
|
+
|
|
9
|
+
export function unsupported(
|
|
10
|
+
provider: string,
|
|
11
|
+
operation: string,
|
|
12
|
+
reason?: string,
|
|
13
|
+
): (...args: unknown[]) => never {
|
|
14
|
+
return () => {
|
|
15
|
+
throw new ProviderUnsupportedError(provider, operation, reason ? { reason } : {})
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/dto/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OAuthTokens` — normalized token payload from a successful
|
|
3
|
+
* code exchange or refresh. Apps persist these against their
|
|
4
|
+
* user record (encrypted via `@strav/kernel`'s cipher).
|
|
5
|
+
*
|
|
6
|
+
* `idToken` is set only for OIDC providers (Google, Line as
|
|
7
|
+
* OpenID Connect). Plain-OAuth2 providers (Facebook) leave it
|
|
8
|
+
* undefined.
|
|
9
|
+
*
|
|
10
|
+
* `expiresAt` is provider-derived (`expires_in` seconds → wall
|
|
11
|
+
* clock). Drivers compute it at exchange time so apps don't
|
|
12
|
+
* need to track when the call was made.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface OAuthTokens {
|
|
16
|
+
accessToken: string
|
|
17
|
+
/** Available only when the user granted offline access (Google) or the provider issues refresh tokens by default (Line). */
|
|
18
|
+
refreshToken?: string
|
|
19
|
+
/** OIDC id_token (JWT). Present on `openid` flows. Apps that already trust the access token usually ignore this. */
|
|
20
|
+
idToken?: string
|
|
21
|
+
expiresAt?: Date
|
|
22
|
+
/** Space-separated scope string the provider granted (may be narrower than what was requested). */
|
|
23
|
+
scope?: string
|
|
24
|
+
tokenType: string
|
|
25
|
+
raw: unknown
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialProfile` — normalized user-info shape across providers.
|
|
3
|
+
* The native provider object is on `.raw`; apps reach there for
|
|
4
|
+
* fields the framework doesn't normalize (Line's `pictureUrl`
|
|
5
|
+
* vs Google's `picture` collapse to `avatarUrl`; Line's
|
|
6
|
+
* `displayName` collapses to `name`).
|
|
7
|
+
*
|
|
8
|
+
* Provider divergence the framework intentionally surfaces:
|
|
9
|
+
*
|
|
10
|
+
* - `email` is optional. Line gives it only when the `email`
|
|
11
|
+
* scope is requested AND the user accepts; Facebook gives it
|
|
12
|
+
* only if the app is approved for `email`. Apps that need it
|
|
13
|
+
* check capability AND check `profile.email`.
|
|
14
|
+
*
|
|
15
|
+
* - `emailVerified` is true only when the provider asserts it.
|
|
16
|
+
* Google + Line always assert; Facebook never does — apps
|
|
17
|
+
* verify themselves.
|
|
18
|
+
*
|
|
19
|
+
* - `id` is the provider-native subject id. Globally unique
|
|
20
|
+
* within the provider's namespace, not across providers.
|
|
21
|
+
* The `(provider, id)` pair is what `social_account` rows
|
|
22
|
+
* key on (slice 8.5).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SocialProfile {
|
|
26
|
+
/** Provider-native user id (`sub` in OIDC, `userId` in Line, `id` in Facebook). */
|
|
27
|
+
id: string
|
|
28
|
+
/** Driver name — `'line'` / `'google'` / `'facebook'`. */
|
|
29
|
+
provider: string
|
|
30
|
+
email?: string
|
|
31
|
+
emailVerified?: boolean
|
|
32
|
+
name?: string
|
|
33
|
+
avatarUrl?: string
|
|
34
|
+
locale?: string
|
|
35
|
+
metadata: Record<string, unknown>
|
|
36
|
+
raw: unknown
|
|
37
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facebook-specific provider config. Apps put one of these
|
|
3
|
+
* inside `config.social.providers[name]` with `driver: 'facebook'`.
|
|
4
|
+
*
|
|
5
|
+
* Get credentials from https://developers.facebook.com → My
|
|
6
|
+
* Apps → "Facebook Login" product → Settings. The `email` scope
|
|
7
|
+
* needs Meta App Review approval before it works for users
|
|
8
|
+
* outside the developer team — set up the review path before
|
|
9
|
+
* going to production.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ProviderConfig } from '../types.ts'
|
|
13
|
+
|
|
14
|
+
export interface FacebookProviderConfig extends ProviderConfig {
|
|
15
|
+
driver: 'facebook'
|
|
16
|
+
clientId: string
|
|
17
|
+
clientSecret: string
|
|
18
|
+
/**
|
|
19
|
+
* Graph API version. Default `'v18.0'`. Apps that need
|
|
20
|
+
* a specific feature pin it explicitly; otherwise the default
|
|
21
|
+
* gets bumped at the next driver release.
|
|
22
|
+
*/
|
|
23
|
+
graphVersion?: string
|
|
24
|
+
/**
|
|
25
|
+
* Profile field list — passed as `?fields=...` on `/me`.
|
|
26
|
+
* Default covers `id,name,email,first_name,last_name,picture,locale`.
|
|
27
|
+
* Apps that need extra fields (e.g. `birthday`, `gender`) override.
|
|
28
|
+
*/
|
|
29
|
+
profileFields?: readonly string[]
|
|
30
|
+
/** Override endpoints for testing — never set in production. */
|
|
31
|
+
endpoints?: {
|
|
32
|
+
authorize?: string
|
|
33
|
+
token?: string
|
|
34
|
+
me?: string
|
|
35
|
+
permissions?: string
|
|
36
|
+
debugToken?: string
|
|
37
|
+
}
|
|
38
|
+
fetch?: typeof fetch
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const GRAPH = 'https://graph.facebook.com'
|
|
42
|
+
const DEFAULT_VERSION = 'v18.0'
|
|
43
|
+
|
|
44
|
+
export function facebookEndpoints(version = DEFAULT_VERSION): {
|
|
45
|
+
authorize: string
|
|
46
|
+
token: string
|
|
47
|
+
me: string
|
|
48
|
+
permissions: string
|
|
49
|
+
debugToken: string
|
|
50
|
+
} {
|
|
51
|
+
return {
|
|
52
|
+
authorize: `https://www.facebook.com/${version}/dialog/oauth`,
|
|
53
|
+
token: `${GRAPH}/${version}/oauth/access_token`,
|
|
54
|
+
me: `${GRAPH}/${version}/me`,
|
|
55
|
+
permissions: `${GRAPH}/${version}/me/permissions`,
|
|
56
|
+
debugToken: `${GRAPH}/${version}/debug_token`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_FACEBOOK_PROFILE_FIELDS: readonly string[] = [
|
|
61
|
+
'id',
|
|
62
|
+
'name',
|
|
63
|
+
'email',
|
|
64
|
+
'first_name',
|
|
65
|
+
'last_name',
|
|
66
|
+
'picture.type(large)',
|
|
67
|
+
'locale',
|
|
68
|
+
]
|