@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TenantedSocialAccount` — typed row of the opt-in tenanted
|
|
3
|
+
* ledger. Identical to `SocialAccount` except its `static schema`
|
|
4
|
+
* points at the tenanted variant, so the Repository runs against
|
|
5
|
+
* the right DDL + RLS policy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { encrypt, Model } from '@strav/database'
|
|
9
|
+
import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
|
|
10
|
+
|
|
11
|
+
export class TenantedSocialAccount extends Model {
|
|
12
|
+
static override readonly schema = tenantedSocialAccountSchema
|
|
13
|
+
|
|
14
|
+
id!: string
|
|
15
|
+
user_id!: string
|
|
16
|
+
provider!: string
|
|
17
|
+
provider_user_id!: string
|
|
18
|
+
email!: string | null
|
|
19
|
+
name!: string | null
|
|
20
|
+
avatar_url!: string | null
|
|
21
|
+
locale!: string | null
|
|
22
|
+
@encrypt access_token!: string
|
|
23
|
+
@encrypt refresh_token!: string | null
|
|
24
|
+
@encrypt id_token!: string | null
|
|
25
|
+
expires_at!: Date | null
|
|
26
|
+
scope!: string | null
|
|
27
|
+
metadata!: Record<string, unknown>
|
|
28
|
+
created_at!: Date
|
|
29
|
+
updated_at!: Date
|
|
30
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TenantedSocialAccountRepository` — same surface as
|
|
3
|
+
* `SocialAccountRepository`, scoped to the tenanted schema.
|
|
4
|
+
* Callers MUST be inside a `TenantManager.withTenant(...)`
|
|
5
|
+
* scope; the INSERT relies on the session's `app.tenant_id`
|
|
6
|
+
* setting (RLS).
|
|
7
|
+
*
|
|
8
|
+
* The implementation deliberately mirrors the non-tenanted
|
|
9
|
+
* Repository line-for-line — minor code duplication is worth
|
|
10
|
+
* it to keep both variants narrowly scoped + avoid runtime
|
|
11
|
+
* branching on a tenancy flag.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// biome-ignore lint/style/useImportType: PostgresDatabase value import for @inject() metadata.
|
|
15
|
+
import { PostgresDatabase, quoteIdent, Repository, SchemaRegistry } from '@strav/database'
|
|
16
|
+
// biome-ignore lint/style/useImportType: Cipher + EventBus value imports for @inject() metadata.
|
|
17
|
+
import { Cipher, EventBus, inject, ulid } from '@strav/kernel'
|
|
18
|
+
import type { OAuthTokens, SocialProfile } from '../dto/index.ts'
|
|
19
|
+
import { SocialAccountAlreadyLinkedError } from '../ledger/social_account_repository.ts'
|
|
20
|
+
import { TenantedSocialAccount } from './tenanted_social_account.ts'
|
|
21
|
+
import { tenantedSocialAccountSchema } from './tenanted_social_account_schema.ts'
|
|
22
|
+
|
|
23
|
+
export interface ConnectInput {
|
|
24
|
+
userId: string
|
|
25
|
+
provider: string
|
|
26
|
+
profile: SocialProfile
|
|
27
|
+
tokens: OAuthTokens
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DisconnectInput {
|
|
31
|
+
userId: string
|
|
32
|
+
provider: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@inject()
|
|
36
|
+
export class TenantedSocialAccountRepository extends Repository<TenantedSocialAccount> {
|
|
37
|
+
static override readonly schema = tenantedSocialAccountSchema
|
|
38
|
+
static override readonly model = TenantedSocialAccount
|
|
39
|
+
|
|
40
|
+
// biome-ignore lint/complexity/noUselessConstructor: explicit constructor forces TS to emit `design:paramtypes` for @inject(). The fourth param is the Cipher for @encrypt token columns.
|
|
41
|
+
constructor(
|
|
42
|
+
db: PostgresDatabase,
|
|
43
|
+
events: EventBus,
|
|
44
|
+
registry?: SchemaRegistry,
|
|
45
|
+
cipher?: Cipher,
|
|
46
|
+
) {
|
|
47
|
+
super(db, events, registry, cipher)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async connect(input: ConnectInput): Promise<TenantedSocialAccount> {
|
|
51
|
+
const existing = await this.findByProviderIdentity(
|
|
52
|
+
input.provider,
|
|
53
|
+
input.profile.id,
|
|
54
|
+
)
|
|
55
|
+
const now = new Date()
|
|
56
|
+
|
|
57
|
+
if (existing) {
|
|
58
|
+
if (existing.user_id !== input.userId) {
|
|
59
|
+
throw new SocialAccountAlreadyLinkedError({
|
|
60
|
+
provider: input.provider,
|
|
61
|
+
providerUserId: input.profile.id,
|
|
62
|
+
existingUserId: existing.user_id,
|
|
63
|
+
attemptedUserId: input.userId,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return this.update(existing, {
|
|
67
|
+
email: input.profile.email ?? null,
|
|
68
|
+
name: input.profile.name ?? null,
|
|
69
|
+
avatar_url: input.profile.avatarUrl ?? null,
|
|
70
|
+
locale: input.profile.locale ?? null,
|
|
71
|
+
access_token: input.tokens.accessToken,
|
|
72
|
+
refresh_token: input.tokens.refreshToken ?? null,
|
|
73
|
+
id_token: input.tokens.idToken ?? null,
|
|
74
|
+
expires_at: input.tokens.expiresAt ?? null,
|
|
75
|
+
scope: input.tokens.scope ?? null,
|
|
76
|
+
updated_at: now,
|
|
77
|
+
} as Partial<TenantedSocialAccount>)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.create({
|
|
81
|
+
id: ulid(),
|
|
82
|
+
user_id: input.userId,
|
|
83
|
+
provider: input.provider,
|
|
84
|
+
provider_user_id: input.profile.id,
|
|
85
|
+
email: input.profile.email ?? null,
|
|
86
|
+
name: input.profile.name ?? null,
|
|
87
|
+
avatar_url: input.profile.avatarUrl ?? null,
|
|
88
|
+
locale: input.profile.locale ?? null,
|
|
89
|
+
access_token: input.tokens.accessToken,
|
|
90
|
+
refresh_token: input.tokens.refreshToken ?? null,
|
|
91
|
+
id_token: input.tokens.idToken ?? null,
|
|
92
|
+
expires_at: input.tokens.expiresAt ?? null,
|
|
93
|
+
scope: input.tokens.scope ?? null,
|
|
94
|
+
metadata: {},
|
|
95
|
+
created_at: now,
|
|
96
|
+
updated_at: now,
|
|
97
|
+
} as Partial<TenantedSocialAccount>)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async disconnect(input: DisconnectInput): Promise<void> {
|
|
101
|
+
const table = quoteIdent(tenantedSocialAccountSchema.name)
|
|
102
|
+
await this.db.execute(
|
|
103
|
+
`DELETE FROM ${table} WHERE "user_id" = $1 AND "provider" = $2`,
|
|
104
|
+
[input.userId, input.provider],
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async findByUser(userId: string): Promise<TenantedSocialAccount[]> {
|
|
109
|
+
const table = quoteIdent(tenantedSocialAccountSchema.name)
|
|
110
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
111
|
+
`SELECT * FROM ${table} WHERE "user_id" = $1 ORDER BY "created_at"`,
|
|
112
|
+
[userId],
|
|
113
|
+
)
|
|
114
|
+
return Promise.all(rows.map((r) => this.hydrate(r)))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async findByUserAndProvider(
|
|
118
|
+
userId: string,
|
|
119
|
+
provider: string,
|
|
120
|
+
): Promise<TenantedSocialAccount | null> {
|
|
121
|
+
const table = quoteIdent(tenantedSocialAccountSchema.name)
|
|
122
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
123
|
+
`SELECT * FROM ${table} WHERE "user_id" = $1 AND "provider" = $2 LIMIT 1`,
|
|
124
|
+
[userId, provider],
|
|
125
|
+
)
|
|
126
|
+
if (rows.length === 0) return null
|
|
127
|
+
return this.hydrate(rows[0]!)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sign-in lookup. Scoped by RLS to the current tenant — the
|
|
132
|
+
* same provider identity can exist in two tenants without
|
|
133
|
+
* collision; this query only sees the one in scope.
|
|
134
|
+
*/
|
|
135
|
+
async findByProviderIdentity(
|
|
136
|
+
provider: string,
|
|
137
|
+
providerUserId: string,
|
|
138
|
+
): Promise<TenantedSocialAccount | null> {
|
|
139
|
+
const table = quoteIdent(tenantedSocialAccountSchema.name)
|
|
140
|
+
const rows = await this.db.query<Record<string, unknown>>(
|
|
141
|
+
`SELECT * FROM ${table}
|
|
142
|
+
WHERE "provider" = $1 AND "provider_user_id" = $2
|
|
143
|
+
LIMIT 1`,
|
|
144
|
+
[provider, providerUserId],
|
|
145
|
+
)
|
|
146
|
+
if (rows.length === 0) return null
|
|
147
|
+
return this.hydrate(rows[0]!)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tenantedSocialAccountSchema` — opt-in tenant-scoped variant
|
|
3
|
+
* of the social-account ledger. Imported from
|
|
4
|
+
* `@strav/social/tenanted` so apps that don't need
|
|
5
|
+
* multitenancy don't pay for it.
|
|
6
|
+
*
|
|
7
|
+
* Same columns as the default `socialAccountSchema`, with
|
|
8
|
+
* `tenanted: true` so `@strav/database` injects the
|
|
9
|
+
* `tenant_id` FK + RLS policy. Composite unique becomes
|
|
10
|
+
* `(tenant_id, provider, provider_user_id)` — the same Google
|
|
11
|
+
* account can be linked across distinct tenants (one per
|
|
12
|
+
* tenant), but only once per tenant.
|
|
13
|
+
*
|
|
14
|
+
* Apps register this schema instead of (or alongside, under a
|
|
15
|
+
* different name) the default. The matching `Model` +
|
|
16
|
+
* `Repository` + `applyTenantedSocialAccountMigration` ship
|
|
17
|
+
* here too so the wiring stays consistent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Archetype, defineSchema } from '@strav/database'
|
|
21
|
+
|
|
22
|
+
export const tenantedSocialAccountSchema = defineSchema(
|
|
23
|
+
'social_account',
|
|
24
|
+
Archetype.Entity,
|
|
25
|
+
(t) => {
|
|
26
|
+
t.id()
|
|
27
|
+
t.string('user_id').max(64).notNull()
|
|
28
|
+
t.string('provider').max(64).notNull()
|
|
29
|
+
t.string('provider_user_id').max(255).notNull()
|
|
30
|
+
t.string('email').max(320).nullable()
|
|
31
|
+
t.string('name').max(255).nullable()
|
|
32
|
+
t.string('avatar_url').max(1024).nullable()
|
|
33
|
+
t.string('locale').max(16).nullable()
|
|
34
|
+
t.encrypted('access_token').notNull()
|
|
35
|
+
t.encrypted('refresh_token').nullable()
|
|
36
|
+
t.encrypted('id_token').nullable()
|
|
37
|
+
t.timestamp('expires_at').nullable()
|
|
38
|
+
t.string('scope').max(512).nullable()
|
|
39
|
+
t.json('metadata').notNull().default({})
|
|
40
|
+
t.timestamp('created_at').notNull()
|
|
41
|
+
t.timestamp('updated_at').notNull()
|
|
42
|
+
},
|
|
43
|
+
{ tenanted: true },
|
|
44
|
+
)
|
package/src/types.ts
CHANGED
|
@@ -1,49 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* application user — linking by an unverified email is a known account-
|
|
10
|
-
* takeover vector. See packages/social/CLAUDE.md ("Verified-email gate").
|
|
11
|
-
*/
|
|
12
|
-
emailVerified: boolean
|
|
13
|
-
avatar: string | null
|
|
14
|
-
nickname: string | null
|
|
15
|
-
token: string
|
|
16
|
-
refreshToken: string | null
|
|
17
|
-
expiresIn: number | null
|
|
18
|
-
approvedScopes: string[]
|
|
19
|
-
raw: Record<string, unknown>
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ProviderConfig {
|
|
23
|
-
driver?: string
|
|
24
|
-
clientId: string
|
|
25
|
-
clientSecret: string
|
|
26
|
-
redirectUrl: string
|
|
27
|
-
scopes?: string[]
|
|
28
|
-
/**
|
|
29
|
-
* How to authenticate with the provider's token endpoint. Default
|
|
30
|
-
* `'basic'` (HTTP Basic auth — RFC 6749 §2.3.1, MUST-support, keeps
|
|
31
|
-
* `client_secret` out of body-logging surfaces). `'post'` falls back
|
|
32
|
-
* to `client_secret` in the request body for providers that don't
|
|
33
|
-
* accept Basic (e.g. Facebook). The `social` package picks the right
|
|
34
|
-
* default per provider but you can override here if needed.
|
|
35
|
-
*/
|
|
36
|
-
tokenEndpointAuthMethod?: 'basic' | 'post'
|
|
37
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `@strav/social` — runtime config shape.
|
|
3
|
+
*
|
|
4
|
+
* Multi-provider by default — apps register one entry per
|
|
5
|
+
* provider they want available (e.g. `line`, `google`,
|
|
6
|
+
* `facebook`). The driver field discriminates the adapter; the
|
|
7
|
+
* rest is driver-specific (`clientId`, `clientSecret`, …).
|
|
8
|
+
*/
|
|
38
9
|
|
|
39
10
|
export interface SocialConfig {
|
|
40
|
-
|
|
11
|
+
/** Default routing target for unqualified `social.*` calls. Keyed into `providers`. */
|
|
12
|
+
default: string
|
|
41
13
|
providers: Record<string, ProviderConfig>
|
|
42
14
|
}
|
|
43
15
|
|
|
44
|
-
export interface
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
16
|
+
export interface ProviderConfig {
|
|
17
|
+
/** Driver identifier — must match a built-in (`'line'` / `'google'` / `'facebook'`) or a name registered via `manager.extend(name, factory)`. */
|
|
18
|
+
driver: string
|
|
19
|
+
/** Driver-specific fields — see each adapter's `*Config`. */
|
|
20
|
+
[key: string]: unknown
|
|
49
21
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.1
|
|
4
|
-
|
|
5
|
-
### Added
|
|
6
|
-
|
|
7
|
-
- Facebook provider (Graph API v21.0)
|
|
8
|
-
- LinkedIn provider (OpenID Connect userinfo endpoint)
|
|
9
|
-
|
|
10
|
-
## 0.1.0
|
|
11
|
-
|
|
12
|
-
### Added
|
|
13
|
-
|
|
14
|
-
- Initial release with OAuth 2.0 social authentication
|
|
15
|
-
- Built-in providers: Google, GitHub, Discord
|
|
16
|
-
- `SocialManager` with driver-based architecture and custom provider extensibility
|
|
17
|
-
- Session-based CSRF state verification with `stateless()` opt-out
|
|
18
|
-
- `social` helper for fluent API access
|
|
19
|
-
- `AbstractProvider` base class for building custom providers
|
package/README.md
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# @strav/social
|
|
2
|
-
|
|
3
|
-
OAuth social authentication for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Sign in with Google, GitHub, Discord, Facebook, and LinkedIn using a fluent, driver-based API.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun add @strav/social
|
|
9
|
-
bun strav install social
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Requires `@strav/core` as a peer dependency.
|
|
13
|
-
|
|
14
|
-
## Setup
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
import { SocialProvider } from '@strav/social'
|
|
18
|
-
|
|
19
|
-
app.use(new SocialProvider())
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Usage
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import { social } from '@strav/social'
|
|
26
|
-
|
|
27
|
-
// Redirect to provider
|
|
28
|
-
r.get('/auth/github', ctx => {
|
|
29
|
-
return social.driver('github').redirect(ctx)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// Handle callback
|
|
33
|
-
r.get('/auth/github/callback', async ctx => {
|
|
34
|
-
const githubUser = await social.driver('github').user(ctx)
|
|
35
|
-
|
|
36
|
-
// githubUser.id, .name, .email, .avatar, .nickname, .token
|
|
37
|
-
let user = await User.findBy('email', githubUser.email)
|
|
38
|
-
if (!user) {
|
|
39
|
-
user = await User.create({ name: githubUser.name, email: githubUser.email })
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
await social.findOrCreate('github', githubUser, user)
|
|
43
|
-
// authenticate session...
|
|
44
|
-
})
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## Providers
|
|
48
|
-
|
|
49
|
-
- **Google** — OpenID Connect with Workspace domain restriction
|
|
50
|
-
- **GitHub** — User profile with verified email fallback
|
|
51
|
-
- **Discord** — Profile with computed avatar URLs
|
|
52
|
-
- **Facebook** — Graph API v21.0
|
|
53
|
-
- **LinkedIn** — OpenID Connect userinfo
|
|
54
|
-
|
|
55
|
-
## Fluent API
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
social.driver('github').scopes(['repo', 'gist']).redirect(ctx)
|
|
59
|
-
social.driver('google').with({ hd: 'example.com' }).redirect(ctx)
|
|
60
|
-
const user = await social.driver('google').userFromToken(accessToken)
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
## Custom Providers
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
import { AbstractProvider, social } from '@strav/social'
|
|
67
|
-
|
|
68
|
-
class SpotifyProvider extends AbstractProvider { /* ... */ }
|
|
69
|
-
social.extend('spotify', config => new SpotifyProvider(config))
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
## Documentation
|
|
73
|
-
|
|
74
|
-
See the full [Social guide](../../guides/social.md).
|
|
75
|
-
|
|
76
|
-
## License
|
|
77
|
-
|
|
78
|
-
MIT
|
package/src/abstract_provider.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import type { Context, Session } from '@strav/http'
|
|
2
|
-
import { randomHex, ExternalServiceError, scrubProviderError } from '@strav/kernel'
|
|
3
|
-
import type { ProviderConfig, SocialUser, TokenResponse } from './types.ts'
|
|
4
|
-
|
|
5
|
-
const STATE_KEY = 'social_state'
|
|
6
|
-
|
|
7
|
-
export abstract class AbstractProvider {
|
|
8
|
-
abstract readonly name: string
|
|
9
|
-
|
|
10
|
-
protected config: ProviderConfig
|
|
11
|
-
protected _scopes: string[]
|
|
12
|
-
protected _parameters: Record<string, string> = {}
|
|
13
|
-
|
|
14
|
-
constructor(config: ProviderConfig) {
|
|
15
|
-
this.config = config
|
|
16
|
-
this._scopes = config.scopes ?? this.getDefaultScopes()
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// Template methods — each provider implements these
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
protected abstract getDefaultScopes(): string[]
|
|
24
|
-
protected abstract getAuthUrl(): string
|
|
25
|
-
protected abstract getTokenUrl(): string
|
|
26
|
-
protected abstract getUserByToken(token: string): Promise<Record<string, unknown>>
|
|
27
|
-
protected abstract mapUserToObject(data: Record<string, unknown>): SocialUser
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Fluent API
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
scopes(scopes: string[]): this {
|
|
34
|
-
this._scopes = [...new Set([...this._scopes, ...scopes])]
|
|
35
|
-
return this
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
setScopes(scopes: string[]): this {
|
|
39
|
-
this._scopes = scopes
|
|
40
|
-
return this
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
with(params: Record<string, string>): this {
|
|
44
|
-
this._parameters = { ...this._parameters, ...params }
|
|
45
|
-
return this
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
// OAuth flow
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
redirect(ctx: Context): Response {
|
|
53
|
-
const state = randomHex(32)
|
|
54
|
-
const session = ctx.get<Session>('session')
|
|
55
|
-
session.set(STATE_KEY, state)
|
|
56
|
-
|
|
57
|
-
const url = this.buildAuthUrl(state)
|
|
58
|
-
return ctx.redirect(url)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async user(ctx: Context): Promise<SocialUser> {
|
|
62
|
-
const session = ctx.get<Session>('session')
|
|
63
|
-
const expectedState = session.get<string>(STATE_KEY)
|
|
64
|
-
const returnedState = ctx.query.get('state')
|
|
65
|
-
|
|
66
|
-
if (!expectedState || expectedState !== returnedState) {
|
|
67
|
-
throw new SocialError('Invalid state parameter. Possible CSRF attack.')
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
session.forget(STATE_KEY)
|
|
71
|
-
|
|
72
|
-
const code = ctx.query.get('code')
|
|
73
|
-
if (!code) {
|
|
74
|
-
const error = ctx.query.get('error')
|
|
75
|
-
throw new SocialError(error ? `OAuth error: ${error}` : 'Missing authorization code.')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const token = await this.getAccessToken(code)
|
|
79
|
-
const data = await this.getUserByToken(token.accessToken)
|
|
80
|
-
const user = this.mapUserToObject(data)
|
|
81
|
-
|
|
82
|
-
user.token = token.accessToken
|
|
83
|
-
user.refreshToken = token.refreshToken
|
|
84
|
-
user.expiresIn = token.expiresIn
|
|
85
|
-
user.approvedScopes = token.scope ? token.scope.split(/[\s,]+/) : this._scopes
|
|
86
|
-
user.raw = data
|
|
87
|
-
|
|
88
|
-
return user
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async userFromToken(token: string): Promise<SocialUser> {
|
|
92
|
-
const data = await this.getUserByToken(token)
|
|
93
|
-
const user = this.mapUserToObject(data)
|
|
94
|
-
|
|
95
|
-
user.token = token
|
|
96
|
-
user.refreshToken = null
|
|
97
|
-
user.expiresIn = null
|
|
98
|
-
user.approvedScopes = this._scopes
|
|
99
|
-
user.raw = data
|
|
100
|
-
|
|
101
|
-
return user
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Internal
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Per-provider override of the default token-endpoint auth method.
|
|
110
|
-
* Override this in subclasses for providers that require a non-`basic`
|
|
111
|
-
* default (e.g. Facebook prefers `post`).
|
|
112
|
-
*/
|
|
113
|
-
protected defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
|
|
114
|
-
return 'basic'
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
protected async getAccessToken(code: string): Promise<TokenResponse> {
|
|
118
|
-
const method = this.config.tokenEndpointAuthMethod ?? this.defaultTokenEndpointAuthMethod()
|
|
119
|
-
|
|
120
|
-
const headers: Record<string, string> = {
|
|
121
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
122
|
-
Accept: 'application/json',
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const body: Record<string, string> = {
|
|
126
|
-
grant_type: 'authorization_code',
|
|
127
|
-
client_id: this.config.clientId,
|
|
128
|
-
code,
|
|
129
|
-
redirect_uri: this.config.redirectUrl,
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (method === 'basic') {
|
|
133
|
-
// RFC 6749 §2.3.1 — MUST-support form. Keeps client_secret out of
|
|
134
|
-
// body-logging surfaces.
|
|
135
|
-
const credentials = `${this.config.clientId}:${this.config.clientSecret}`
|
|
136
|
-
headers.Authorization = `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`
|
|
137
|
-
} else {
|
|
138
|
-
// Fallback for providers that only accept secret-in-body.
|
|
139
|
-
body.client_secret = this.config.clientSecret
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const response = await fetch(this.getTokenUrl(), {
|
|
143
|
-
method: 'POST',
|
|
144
|
-
headers,
|
|
145
|
-
body: new URLSearchParams(body),
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
if (!response.ok) {
|
|
149
|
-
const text = await response.text()
|
|
150
|
-
throw new ExternalServiceError(this.name, response.status, scrubProviderError(text))
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const data = (await response.json()) as Record<string, unknown>
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
accessToken: data.access_token as string,
|
|
157
|
-
refreshToken: (data.refresh_token as string) ?? null,
|
|
158
|
-
expiresIn: (data.expires_in as number) ?? null,
|
|
159
|
-
scope: (data.scope as string) ?? null,
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
protected buildAuthUrl(state: string): string {
|
|
164
|
-
const params = new URLSearchParams({
|
|
165
|
-
client_id: this.config.clientId,
|
|
166
|
-
redirect_uri: this.config.redirectUrl,
|
|
167
|
-
response_type: 'code',
|
|
168
|
-
scope: this._scopes.join(' '),
|
|
169
|
-
state,
|
|
170
|
-
...this._parameters,
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
return `${this.getAuthUrl()}?${params.toString()}`
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export class SocialError extends Error {
|
|
178
|
-
constructor(message: string) {
|
|
179
|
-
super(message)
|
|
180
|
-
this.name = 'SocialError'
|
|
181
|
-
}
|
|
182
|
-
}
|
package/src/helpers.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { AbstractProvider } from './abstract_provider.ts'
|
|
2
|
-
import SocialAccount from './social_account.ts'
|
|
3
|
-
import SocialManager from './social_manager.ts'
|
|
4
|
-
import type { ProviderConfig, SocialUser } from './types.ts'
|
|
5
|
-
import type { SocialAccountData } from './social_account.ts'
|
|
6
|
-
|
|
7
|
-
export const social = {
|
|
8
|
-
driver(name: string): AbstractProvider {
|
|
9
|
-
return SocialManager.driver(name)
|
|
10
|
-
},
|
|
11
|
-
|
|
12
|
-
extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
|
|
13
|
-
SocialManager.extend(name, factory)
|
|
14
|
-
},
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Find an existing social account by provider or create a new one.
|
|
18
|
-
* If the account already exists, its tokens are updated.
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* const socialUser = await social.driver('google').user(ctx)
|
|
22
|
-
* const { account, created } = await social.findOrCreate('google', socialUser, user)
|
|
23
|
-
*/
|
|
24
|
-
findOrCreate(
|
|
25
|
-
provider: string,
|
|
26
|
-
socialUser: SocialUser,
|
|
27
|
-
user: unknown
|
|
28
|
-
): Promise<{ account: SocialAccountData; created: boolean }> {
|
|
29
|
-
return SocialAccount.findOrCreate(provider, socialUser, user)
|
|
30
|
-
},
|
|
31
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { ExternalServiceError, scrubProviderError } from '@strav/kernel'
|
|
2
|
-
import { AbstractProvider } from '../abstract_provider.ts'
|
|
3
|
-
import type { SocialUser } from '../types.ts'
|
|
4
|
-
|
|
5
|
-
export class DiscordProvider extends AbstractProvider {
|
|
6
|
-
readonly name = 'Discord'
|
|
7
|
-
|
|
8
|
-
protected getDefaultScopes(): string[] {
|
|
9
|
-
return ['identify', 'email']
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
protected getAuthUrl(): string {
|
|
13
|
-
return 'https://discord.com/api/oauth2/authorize'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected getTokenUrl(): string {
|
|
17
|
-
return 'https://discord.com/api/oauth2/token'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
-
const response = await fetch('https://discord.com/api/v10/users/@me', {
|
|
22
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
throw new ExternalServiceError(
|
|
27
|
-
'Discord',
|
|
28
|
-
response.status,
|
|
29
|
-
scrubProviderError(await response.text())
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return (await response.json()) as Record<string, unknown>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
37
|
-
let avatar: string | null = null
|
|
38
|
-
if (data.avatar) {
|
|
39
|
-
avatar = `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png`
|
|
40
|
-
} else if (data.id) {
|
|
41
|
-
const index = Number((BigInt(data.id as string) >> 22n) % 6n)
|
|
42
|
-
avatar = `https://cdn.discordapp.com/embed/avatars/${index}.png`
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
id: data.id as string,
|
|
47
|
-
name: (data.global_name as string) ?? null,
|
|
48
|
-
email: (data.email as string) ?? null,
|
|
49
|
-
emailVerified: data.verified === true,
|
|
50
|
-
avatar,
|
|
51
|
-
nickname: (data.username as string) ?? null,
|
|
52
|
-
token: '',
|
|
53
|
-
refreshToken: null,
|
|
54
|
-
expiresIn: null,
|
|
55
|
-
approvedScopes: [],
|
|
56
|
-
raw: data,
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|