@strav/social 0.4.31 → 1.0.0-alpha.24
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/facebook/facebook_config.ts +68 -0
- package/src/drivers/facebook/facebook_driver.ts +321 -0
- package/src/drivers/facebook/facebook_provider.ts +29 -0
- package/src/drivers/facebook/index.ts +12 -0
- package/src/drivers/google/google_config.ts +44 -0
- package/src/drivers/google/google_driver.ts +317 -0
- package/src/drivers/google/google_provider.ts +33 -0
- package/src/drivers/google/index.ts +12 -0
- package/src/drivers/index.ts +2 -0
- package/src/drivers/line/index.ts +12 -0
- package/src/drivers/line/line_config.ts +47 -0
- package/src/drivers/line/line_driver.ts +310 -0
- package/src/drivers/line/line_provider.ts +34 -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/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 +203 -0
- package/src/ledger/social_account_schema.ts +75 -0
- package/src/pkce.ts +63 -0
- package/src/social_capabilities.ts +35 -0
- package/src/social_driver.ts +143 -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 +136 -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,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
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SocialAccountRepository` — domain helpers on top of the
|
|
3
|
+
* generic CRUD surface. The four methods apps actually use:
|
|
4
|
+
*
|
|
5
|
+
* - `connect({ userId, provider, profile, tokens })` — upsert
|
|
6
|
+
* by `(provider, provider_user_id)` within the tenant scope.
|
|
7
|
+
* Runs on every sign-in: a returning user's tokens get
|
|
8
|
+
* refreshed; a first-time link inserts.
|
|
9
|
+
*
|
|
10
|
+
* - `disconnect({ userId, provider })` — delete the link.
|
|
11
|
+
* Apps invoke this when the user unlinks a provider; for
|
|
12
|
+
* full token revocation, drivers' `revoke()` runs separately
|
|
13
|
+
* (this method doesn't reach the provider).
|
|
14
|
+
*
|
|
15
|
+
* - `findByUser(userId)` — list every linked provider for a
|
|
16
|
+
* user. Apps render account-settings UIs from this.
|
|
17
|
+
*
|
|
18
|
+
* - `findByProviderIdentity(provider, providerUserId)` — the
|
|
19
|
+
* sign-in lookup: "we just verified an OAuth identity, who
|
|
20
|
+
* does it belong to?" Returns the row including `user_id`
|
|
21
|
+
* so the caller can hydrate the app's User.
|
|
22
|
+
*
|
|
23
|
+
* Tokens are encrypted via the Model's `@encrypt` decorators —
|
|
24
|
+
* Repository hydration handles it transparently. Apps must have
|
|
25
|
+
* an `EncryptionProvider` registered; otherwise the first
|
|
26
|
+
* encrypt/decrypt throws `ConfigError`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { quoteIdent, Repository } from '@strav/database'
|
|
30
|
+
import { ulid } from '@strav/kernel'
|
|
31
|
+
import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
|
|
32
|
+
import { SocialAccount } from './social_account.ts'
|
|
33
|
+
import { socialAccountSchema } from './social_account_schema.ts'
|
|
34
|
+
|
|
35
|
+
export interface ConnectInput {
|
|
36
|
+
/** App-side user id. */
|
|
37
|
+
userId: string
|
|
38
|
+
/** Provider-instance name (matches `social.use(name)`). Distinct from `profile.provider` when one driver is wired under multiple names. */
|
|
39
|
+
provider: string
|
|
40
|
+
profile: SocialProfile
|
|
41
|
+
tokens: OAuthTokens
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DisconnectInput {
|
|
45
|
+
userId: string
|
|
46
|
+
provider: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SocialAccountRepository extends Repository<SocialAccount> {
|
|
50
|
+
static override readonly schema = socialAccountSchema
|
|
51
|
+
static override readonly model = SocialAccount
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Upsert a social account by `(provider, provider_user_id)`.
|
|
55
|
+
* Insert on first link; update tokens + cached profile fields
|
|
56
|
+
* on subsequent sign-ins.
|
|
57
|
+
*
|
|
58
|
+
* No tenant scoping required — the default schema is
|
|
59
|
+
* non-tenanted. Apps that opted into the tenanted variant
|
|
60
|
+
* (`@strav/social/tenanted`) wrap calls in
|
|
61
|
+
* `TenantManager.withTenant(...)`; that variant ships its own
|
|
62
|
+
* Repository.
|
|
63
|
+
*/
|
|
64
|
+
async connect(input: ConnectInput): Promise<SocialAccount> {
|
|
65
|
+
const existing = await this.findByProviderIdentity(
|
|
66
|
+
input.provider,
|
|
67
|
+
input.profile.id,
|
|
68
|
+
)
|
|
69
|
+
const now = new Date()
|
|
70
|
+
|
|
71
|
+
if (existing) {
|
|
72
|
+
// Cross-user link guard: if the existing row belongs to a
|
|
73
|
+
// different user, refuse — the app needs to resolve the
|
|
74
|
+
// conflict explicitly (typically "this Google account is
|
|
75
|
+
// already linked to another user").
|
|
76
|
+
if (existing.user_id !== input.userId) {
|
|
77
|
+
throw new SocialAccountAlreadyLinkedError({
|
|
78
|
+
provider: input.provider,
|
|
79
|
+
providerUserId: input.profile.id,
|
|
80
|
+
existingUserId: existing.user_id,
|
|
81
|
+
attemptedUserId: input.userId,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
// Same user, returning sign-in: refresh tokens + cached
|
|
85
|
+
// profile fields via the standard Repository.update path
|
|
86
|
+
// (handles `@cast` / `@encrypt` round-trips for us).
|
|
87
|
+
return this.update(existing, {
|
|
88
|
+
email: input.profile.email ?? null,
|
|
89
|
+
name: input.profile.name ?? null,
|
|
90
|
+
avatar_url: input.profile.avatarUrl ?? null,
|
|
91
|
+
locale: input.profile.locale ?? null,
|
|
92
|
+
access_token: input.tokens.accessToken,
|
|
93
|
+
refresh_token: input.tokens.refreshToken ?? null,
|
|
94
|
+
id_token: input.tokens.idToken ?? null,
|
|
95
|
+
expires_at: input.tokens.expiresAt ?? null,
|
|
96
|
+
scope: input.tokens.scope ?? null,
|
|
97
|
+
updated_at: now,
|
|
98
|
+
} as Partial<SocialAccount>)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return this.create({
|
|
102
|
+
id: ulid(),
|
|
103
|
+
user_id: input.userId,
|
|
104
|
+
provider: input.provider,
|
|
105
|
+
provider_user_id: input.profile.id,
|
|
106
|
+
email: input.profile.email ?? null,
|
|
107
|
+
name: input.profile.name ?? null,
|
|
108
|
+
avatar_url: input.profile.avatarUrl ?? null,
|
|
109
|
+
locale: input.profile.locale ?? null,
|
|
110
|
+
access_token: input.tokens.accessToken,
|
|
111
|
+
refresh_token: input.tokens.refreshToken ?? null,
|
|
112
|
+
id_token: input.tokens.idToken ?? null,
|
|
113
|
+
expires_at: input.tokens.expiresAt ?? null,
|
|
114
|
+
scope: input.tokens.scope ?? null,
|
|
115
|
+
metadata: {},
|
|
116
|
+
created_at: now,
|
|
117
|
+
updated_at: now,
|
|
118
|
+
} as Partial<SocialAccount>)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Delete the link. No-op when nothing matches. */
|
|
122
|
+
async disconnect(input: DisconnectInput): Promise<void> {
|
|
123
|
+
const table = quoteIdent(socialAccountSchema.name)
|
|
124
|
+
await this.db.execute(
|
|
125
|
+
`DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
|
|
126
|
+
[input.userId, input.provider],
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every social account linked to one user. */
|
|
131
|
+
async findByUser(userId: string): Promise<SocialAccount[]> {
|
|
132
|
+
const table = quoteIdent(socialAccountSchema.name)
|
|
133
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
134
|
+
`SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
|
|
135
|
+
[userId],
|
|
136
|
+
)
|
|
137
|
+
return Promise.all(rows.map((r) => this.hydrate(r)))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Single (user, provider) lookup. */
|
|
141
|
+
async findByUserAndProvider(
|
|
142
|
+
userId: string,
|
|
143
|
+
provider: string,
|
|
144
|
+
): Promise<SocialAccount | null> {
|
|
145
|
+
const table = quoteIdent(socialAccountSchema.name)
|
|
146
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
147
|
+
`SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
|
|
148
|
+
[userId, provider],
|
|
149
|
+
)
|
|
150
|
+
if (rows.length === 0) return null
|
|
151
|
+
return this.hydrate(rows[0]!)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The sign-in lookup: given an OAuth identity, find the user
|
|
156
|
+
* it belongs to. Returns the account row (which includes
|
|
157
|
+
* `user_id`) or `null` when no link exists.
|
|
158
|
+
*/
|
|
159
|
+
async findByProviderIdentity(
|
|
160
|
+
provider: string,
|
|
161
|
+
providerUserId: string,
|
|
162
|
+
): Promise<SocialAccount | null> {
|
|
163
|
+
const table = quoteIdent(socialAccountSchema.name)
|
|
164
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
165
|
+
`SELECT * FROM ${table}
|
|
166
|
+
WHERE "provider" = $1 AND "provider_user_id" = $2
|
|
167
|
+
LIMIT 1`,
|
|
168
|
+
[provider, providerUserId],
|
|
169
|
+
)
|
|
170
|
+
if (rows.length === 0) return null
|
|
171
|
+
return this.hydrate(rows[0]!)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Thrown when an OAuth identity is already linked to a DIFFERENT
|
|
177
|
+
* user than the caller is trying to attach it to. Apps catch this
|
|
178
|
+
* and surface a UI ("this Google account is already linked to
|
|
179
|
+
* <other_email>"). The framework refuses to silently move the
|
|
180
|
+
* link or fork the row.
|
|
181
|
+
*/
|
|
182
|
+
export class SocialAccountAlreadyLinkedError extends Error {
|
|
183
|
+
readonly provider: string
|
|
184
|
+
readonly providerUserId: string
|
|
185
|
+
readonly existingUserId: string
|
|
186
|
+
readonly attemptedUserId: string
|
|
187
|
+
|
|
188
|
+
constructor(info: {
|
|
189
|
+
provider: string
|
|
190
|
+
providerUserId: string
|
|
191
|
+
existingUserId: string
|
|
192
|
+
attemptedUserId: string
|
|
193
|
+
}) {
|
|
194
|
+
super(
|
|
195
|
+
`SocialAccount: provider "${info.provider}" identity "${info.providerUserId}" is already linked to user "${info.existingUserId}"; refusing to relink to "${info.attemptedUserId}".`,
|
|
196
|
+
)
|
|
197
|
+
this.name = 'SocialAccountAlreadyLinkedError'
|
|
198
|
+
this.provider = info.provider
|
|
199
|
+
this.providerUserId = info.providerUserId
|
|
200
|
+
this.existingUserId = info.existingUserId
|
|
201
|
+
this.attemptedUserId = info.attemptedUserId
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `socialAccountSchema` — ledger of provider identities linked
|
|
3
|
+
* to app users. **Non-tenanted by default** (framework policy:
|
|
4
|
+
* multitenancy is opt-in). Apps that need per-tenant scoping
|
|
5
|
+
* import `tenantedSocialAccountSchema` from
|
|
6
|
+
* `@strav/social/tenanted` instead.
|
|
7
|
+
*
|
|
8
|
+
* Natural key is `(provider, provider_user_id)` — a given
|
|
9
|
+
* Google / Line / Facebook identity belongs to exactly one
|
|
10
|
+
* user. Composite uniqueness lives in the migration (the schema
|
|
11
|
+
* builder only exposes per-column `.unique()`).
|
|
12
|
+
*
|
|
13
|
+
* Tokens are encrypted-at-rest via `@strav/database`'s
|
|
14
|
+
* `t.encrypted(...)` column kind + `@encrypt` decorator on the
|
|
15
|
+
* Model. Apps must have an `EncryptionProvider` registered on
|
|
16
|
+
* the kernel container; otherwise the first repository call
|
|
17
|
+
* throws `ConfigError` at runtime.
|
|
18
|
+
*
|
|
19
|
+
* Why store tokens here at all: many apps need long-term
|
|
20
|
+
* offline access (Google `access_type=offline`) or to revoke
|
|
21
|
+
* later. Apps that only need "did this user sign in via X"
|
|
22
|
+
* set the encrypted columns to a sentinel via the Repository's
|
|
23
|
+
* upsert path and discard the real tokens after first use.
|
|
24
|
+
*
|
|
25
|
+
* Columns:
|
|
26
|
+
*
|
|
27
|
+
* - `id` ULID PK.
|
|
28
|
+
* - `user_id` App-side user reference. Free-form
|
|
29
|
+
* string so apps with ULID / int / uuid
|
|
30
|
+
* PKs all fit.
|
|
31
|
+
* - `provider` Driver identifier (`'line'` /
|
|
32
|
+
* `'google'` / `'facebook'` / custom).
|
|
33
|
+
* - `provider_user_id` Provider-native subject id (Google
|
|
34
|
+
* `sub`, Line `userId`, Facebook `id`).
|
|
35
|
+
* - `email` Last known email — cached for app
|
|
36
|
+
* UI; canonical lookups go to the user.
|
|
37
|
+
* - `name` Last known display name.
|
|
38
|
+
* - `avatar_url` Last known avatar URL.
|
|
39
|
+
* - `locale` Last known locale (where the provider
|
|
40
|
+
* gives one; Line doesn't).
|
|
41
|
+
* - `access_token` Encrypted-at-rest.
|
|
42
|
+
* - `refresh_token` Encrypted-at-rest. Nullable (Facebook
|
|
43
|
+
* doesn't issue them).
|
|
44
|
+
* - `id_token` Encrypted-at-rest. Nullable (OIDC
|
|
45
|
+
* providers only).
|
|
46
|
+
* - `expires_at` When the access token expires.
|
|
47
|
+
* - `scope` Space-separated granted scope string.
|
|
48
|
+
* - `metadata` Free-form jsonb (provider extras).
|
|
49
|
+
* - `created_at` / `updated_at`
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
53
|
+
|
|
54
|
+
export const socialAccountSchema = defineSchema(
|
|
55
|
+
'social_account',
|
|
56
|
+
Archetype.Entity,
|
|
57
|
+
(t) => {
|
|
58
|
+
t.id()
|
|
59
|
+
t.string('user_id').max(64).notNull()
|
|
60
|
+
t.string('provider').max(64).notNull()
|
|
61
|
+
t.string('provider_user_id').max(255).notNull()
|
|
62
|
+
t.string('email').max(320).nullable()
|
|
63
|
+
t.string('name').max(255).nullable()
|
|
64
|
+
t.string('avatar_url').max(1024).nullable()
|
|
65
|
+
t.string('locale').max(16).nullable()
|
|
66
|
+
t.encrypted('access_token').notNull()
|
|
67
|
+
t.encrypted('refresh_token').nullable()
|
|
68
|
+
t.encrypted('id_token').nullable()
|
|
69
|
+
t.timestamp('expires_at').nullable()
|
|
70
|
+
t.string('scope').max(512).nullable()
|
|
71
|
+
t.json('metadata').notNull().default({})
|
|
72
|
+
t.timestamp('created_at').notNull()
|
|
73
|
+
t.timestamp('updated_at').notNull()
|
|
74
|
+
},
|
|
75
|
+
)
|
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,143 @@
|
|
|
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
|
+
* ## Capability gating — the "honest LSP violation" pattern
|
|
9
|
+
*
|
|
10
|
+
* Strictly speaking, the interface below violates Liskov substitution
|
|
11
|
+
* because `FacebookSocialDriver.refresh()` throws synchronously instead
|
|
12
|
+
* of returning `Promise<OAuthTokens>` (Facebook doesn't issue refresh
|
|
13
|
+
* tokens). Same shape for any future driver that lacks an operation
|
|
14
|
+
* its interface declares.
|
|
15
|
+
*
|
|
16
|
+
* The framework prefers this trade-off over the alternative (every
|
|
17
|
+
* provider gets its own narrowed sub-interface, every app does a
|
|
18
|
+
* `if (isRefreshable(driver))` narrowing dance) for three reasons:
|
|
19
|
+
*
|
|
20
|
+
* 1. **Discoverability**: apps see ONE `SocialDriver` interface
|
|
21
|
+
* with all the operations a sign-in flow needs. They learn the
|
|
22
|
+
* surface once, not per-adapter.
|
|
23
|
+
*
|
|
24
|
+
* 2. **Capability flags are the typed truth**: each driver
|
|
25
|
+
* declares `capabilities: ReadonlySet<SocialCapability>`. Apps
|
|
26
|
+
* that care about portability check the flag and branch — the
|
|
27
|
+
* `unsupported` throw becomes the safety net for callers who
|
|
28
|
+
* forgot, not the primary API.
|
|
29
|
+
*
|
|
30
|
+
* 3. **Failures are loud and synchronous**. Drivers use the
|
|
31
|
+
* `unsupported(provider, op, reason?)` helper so the throw
|
|
32
|
+
* happens on the function call, NOT after a network round-trip
|
|
33
|
+
* had a chance to bill the user / consume rate limit. Apps fail
|
|
34
|
+
* fast.
|
|
35
|
+
*
|
|
36
|
+
* Apps that want compile-time safety against unsupported ops wrap the
|
|
37
|
+
* driver themselves:
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* function refreshableDriver(d: SocialDriver) {
|
|
41
|
+
* if (!d.capabilities.has('tokens.refresh')) return null
|
|
42
|
+
* return d // narrowed by convention; refresh() is now safe to call.
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* A split into `SocialDriver` + `RefreshableSocialDriver` + ... may
|
|
47
|
+
* land later (`docs/code-quality.md` action item #4) — but doing so
|
|
48
|
+
* carries the manager's `use(name): SocialDriver` return type through
|
|
49
|
+
* a wider refactor. Until then, the capability set IS the contract.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import type { OAuthTokens, SocialProfile } from './dto/index.ts'
|
|
53
|
+
import type { SocialCapability } from './social_capabilities.ts'
|
|
54
|
+
|
|
55
|
+
export interface AuthorizeInput {
|
|
56
|
+
/**
|
|
57
|
+
* Where the provider redirects after consent. Must match
|
|
58
|
+
* what's registered in the provider's developer console.
|
|
59
|
+
*/
|
|
60
|
+
redirectUri: string
|
|
61
|
+
/**
|
|
62
|
+
* OAuth scope list. Drivers expose `availableScopes` for
|
|
63
|
+
* apps that want to render a picker; apps usually hard-code
|
|
64
|
+
* `['profile', 'email']` or `['openid', 'profile', 'email']`.
|
|
65
|
+
*/
|
|
66
|
+
scopes?: readonly string[]
|
|
67
|
+
/**
|
|
68
|
+
* Override the CSRF state. When omitted, the driver generates
|
|
69
|
+
* one via `randomState()`. Apps that already have a
|
|
70
|
+
* session-bound nonce (and want to use it as state) pass it
|
|
71
|
+
* here.
|
|
72
|
+
*/
|
|
73
|
+
state?: string
|
|
74
|
+
/**
|
|
75
|
+
* Override the PKCE code verifier. When omitted AND the driver
|
|
76
|
+
* supports/requires PKCE, the driver generates one and returns
|
|
77
|
+
* it on the result. Apps store the returned verifier against
|
|
78
|
+
* the session for the callback step.
|
|
79
|
+
*/
|
|
80
|
+
codeVerifier?: string
|
|
81
|
+
/**
|
|
82
|
+
* Provider-specific extra query parameters (e.g. `prompt`,
|
|
83
|
+
* `access_type`, `bot_prompt` for Line). The driver merges
|
|
84
|
+
* these into the authorize URL after the standard params.
|
|
85
|
+
*/
|
|
86
|
+
extra?: Record<string, string>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AuthorizeResult {
|
|
90
|
+
/** The full URL the app redirects the customer to. */
|
|
91
|
+
url: string
|
|
92
|
+
state: string
|
|
93
|
+
/** Set when the driver issued / accepted a PKCE verifier. Apps persist this against the session. */
|
|
94
|
+
codeVerifier?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ExchangeInput {
|
|
98
|
+
code: string
|
|
99
|
+
redirectUri: string
|
|
100
|
+
/** Pass the state value the app stored at authorize-time. The driver verifies it matches `expectedState` (which the app provides on the callback). */
|
|
101
|
+
state?: string
|
|
102
|
+
/** Expected state — `state` from the AuthorizeResult the app stored. */
|
|
103
|
+
expectedState?: string
|
|
104
|
+
/** PKCE verifier — required by drivers that declared `pkce.required`. */
|
|
105
|
+
codeVerifier?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RefreshInput {
|
|
109
|
+
refreshToken: string
|
|
110
|
+
/** Optional narrowed scope list. Most providers ignore this. */
|
|
111
|
+
scopes?: readonly string[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SocialDriver {
|
|
115
|
+
/** Driver identifier — `'line'` / `'google'` / `'facebook'`. */
|
|
116
|
+
readonly name: string
|
|
117
|
+
/** App-chosen instance name (`config.social.providers[name]`). */
|
|
118
|
+
readonly instanceName: string
|
|
119
|
+
readonly capabilities: ReadonlySet<SocialCapability>
|
|
120
|
+
/** Provider-supported scope list. Apps render pickers from this; drivers reject unknown scopes at authorize time. */
|
|
121
|
+
readonly availableScopes: readonly string[]
|
|
122
|
+
|
|
123
|
+
/** Build the authorize URL + emit state / PKCE artefacts the app stores. */
|
|
124
|
+
authorize(input: AuthorizeInput): Promise<AuthorizeResult>
|
|
125
|
+
|
|
126
|
+
/** Exchange the callback code for tokens. Verifies state when both `state` and `expectedState` are provided. */
|
|
127
|
+
exchange(input: ExchangeInput): Promise<OAuthTokens>
|
|
128
|
+
|
|
129
|
+
/** Fetch the normalized user profile using a valid access token. */
|
|
130
|
+
profile(accessToken: string): Promise<SocialProfile>
|
|
131
|
+
|
|
132
|
+
/** Trade a refresh token for a fresh access token. Drivers without the `tokens.refresh` capability throw. */
|
|
133
|
+
refresh(input: RefreshInput): Promise<OAuthTokens>
|
|
134
|
+
|
|
135
|
+
/** Revoke a token. Drivers without `tokens.revoke` throw. */
|
|
136
|
+
revoke(token: string): Promise<void>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Factory the manager invokes per configured provider. */
|
|
140
|
+
export type SocialDriverFactory = (config: {
|
|
141
|
+
instanceName: string
|
|
142
|
+
config: Record<string, unknown> & { driver: string }
|
|
143
|
+
}) => SocialDriver
|