@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.
Files changed (52) hide show
  1. package/package.json +23 -16
  2. package/src/drivers/index.ts +2 -0
  3. package/src/drivers/mock_driver.ts +170 -0
  4. package/src/drivers/unsupported.ts +17 -0
  5. package/src/dto/index.ts +4 -0
  6. package/src/dto/oauth_tokens.ts +26 -0
  7. package/src/dto/social_profile.ts +37 -0
  8. package/src/facebook/facebook_config.ts +68 -0
  9. package/src/facebook/facebook_driver.ts +321 -0
  10. package/src/facebook/facebook_provider.ts +29 -0
  11. package/src/facebook/index.ts +12 -0
  12. package/src/google/google_config.ts +44 -0
  13. package/src/google/google_driver.ts +317 -0
  14. package/src/google/google_provider.ts +33 -0
  15. package/src/google/index.ts +12 -0
  16. package/src/index.ts +61 -14
  17. package/src/ledger/apply_social_account_migration.ts +66 -0
  18. package/src/ledger/index.ts +12 -0
  19. package/src/ledger/social_account.ts +32 -0
  20. package/src/ledger/social_account_repository.ts +216 -0
  21. package/src/ledger/social_account_schema.ts +75 -0
  22. package/src/line/index.ts +12 -0
  23. package/src/line/line_config.ts +47 -0
  24. package/src/line/line_driver.ts +310 -0
  25. package/src/line/line_provider.ts +34 -0
  26. package/src/pkce.ts +63 -0
  27. package/src/social_capabilities.ts +35 -0
  28. package/src/social_driver.ts +105 -0
  29. package/src/social_error.ts +155 -0
  30. package/src/social_manager.ts +92 -74
  31. package/src/social_provider.ts +41 -7
  32. package/src/tenanted/apply_tenanted_social_account_migration.ts +45 -0
  33. package/src/tenanted/index.ts +18 -0
  34. package/src/tenanted/tenanted_social_account.ts +30 -0
  35. package/src/tenanted/tenanted_social_account_repository.ts +149 -0
  36. package/src/tenanted/tenanted_social_account_schema.ts +44 -0
  37. package/src/types.ts +15 -43
  38. package/CHANGELOG.md +0 -19
  39. package/README.md +0 -78
  40. package/src/abstract_provider.ts +0 -182
  41. package/src/helpers.ts +0 -31
  42. package/src/providers/discord_provider.ts +0 -59
  43. package/src/providers/facebook_provider.ts +0 -69
  44. package/src/providers/github_provider.ts +0 -75
  45. package/src/providers/google_provider.ts +0 -51
  46. package/src/providers/line_provider.ts +0 -73
  47. package/src/providers/linkedin_provider.ts +0 -51
  48. package/src/schema.ts +0 -13
  49. package/src/social_account.ts +0 -238
  50. package/stubs/config/social.ts +0 -22
  51. package/stubs/schemas/social_account.ts +0 -13
  52. package/tsconfig.json +0 -5
package/package.json CHANGED
@@ -1,27 +1,34 @@
1
1
  {
2
2
  "name": "@strav/social",
3
- "version": "0.4.31",
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
- "description": "OAuth social authentication for the Strav framework",
6
- "license": "MIT",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
7
8
  "exports": {
8
9
  ".": "./src/index.ts",
9
- "./*": "./src/*.ts"
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
- "stubs/",
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
- "@strav/kernel": "0.4.31",
20
- "@strav/http": "0.4.31",
21
- "@strav/database": "0.4.31"
31
+ "@types/bun": ">=1.3.14"
22
32
  },
23
- "scripts": {
24
- "test": "bun test tests/",
25
- "typecheck": "tsc --noEmit"
26
- }
33
+ "devDependencies": null
27
34
  }
@@ -0,0 +1,2 @@
1
+ export { MockDriver, type MockDriverOptions } from './mock_driver.ts'
2
+ export { unsupported } from './unsupported.ts'
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /** Barrel for the social DTOs. */
2
+
3
+ export type { OAuthTokens } from './oauth_tokens.ts'
4
+ export type { SocialProfile } from './social_profile.ts'
@@ -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
+ ]