@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
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
|
-
}
|
|
@@ -1,69 +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
|
-
const API_VERSION = 'v21.0'
|
|
6
|
-
|
|
7
|
-
export class FacebookProvider extends AbstractProvider {
|
|
8
|
-
readonly name = 'Facebook'
|
|
9
|
-
|
|
10
|
-
protected getDefaultScopes(): string[] {
|
|
11
|
-
return ['email', 'public_profile']
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Facebook's Graph API token endpoint reads `client_secret` from the
|
|
16
|
-
* request body (its docs don't advertise HTTP Basic support reliably),
|
|
17
|
-
* so default to `'post'` here. Apps can still override via
|
|
18
|
-
* `ProviderConfig.tokenEndpointAuthMethod`.
|
|
19
|
-
*/
|
|
20
|
-
protected override defaultTokenEndpointAuthMethod(): 'basic' | 'post' {
|
|
21
|
-
return 'post'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
protected getAuthUrl(): string {
|
|
25
|
-
return `https://www.facebook.com/${API_VERSION}/dialog/oauth`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
protected getTokenUrl(): string {
|
|
29
|
-
return `https://graph.facebook.com/${API_VERSION}/oauth/access_token`
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
33
|
-
const fields = 'id,name,email,picture.type(large)'
|
|
34
|
-
const response = await fetch(
|
|
35
|
-
`https://graph.facebook.com/${API_VERSION}/me?fields=${fields}&access_token=${token}`
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
if (!response.ok) {
|
|
39
|
-
throw new ExternalServiceError(
|
|
40
|
-
'Facebook',
|
|
41
|
-
response.status,
|
|
42
|
-
scrubProviderError(await response.text())
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (await response.json()) as Record<string, unknown>
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
50
|
-
const picture = data.picture as { data?: { url?: string } } | undefined
|
|
51
|
-
const email = (data.email as string) ?? null
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
id: data.id as string,
|
|
55
|
-
name: (data.name as string) ?? null,
|
|
56
|
-
email,
|
|
57
|
-
// Facebook's Graph API only returns the user's verified primary email;
|
|
58
|
-
// an unverified address is omitted from the response entirely.
|
|
59
|
-
emailVerified: email !== null,
|
|
60
|
-
avatar: picture?.data?.url ?? null,
|
|
61
|
-
nickname: null,
|
|
62
|
-
token: '',
|
|
63
|
-
refreshToken: null,
|
|
64
|
-
expiresIn: null,
|
|
65
|
-
approvedScopes: [],
|
|
66
|
-
raw: data,
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
@@ -1,75 +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 GitHubProvider extends AbstractProvider {
|
|
6
|
-
readonly name = 'GitHub'
|
|
7
|
-
|
|
8
|
-
protected getDefaultScopes(): string[] {
|
|
9
|
-
return ['read:user', 'user:email']
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
protected getAuthUrl(): string {
|
|
13
|
-
return 'https://github.com/login/oauth/authorize'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected getTokenUrl(): string {
|
|
17
|
-
return 'https://github.com/login/oauth/access_token'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
-
const headers = {
|
|
22
|
-
Authorization: `Bearer ${token}`,
|
|
23
|
-
Accept: 'application/json',
|
|
24
|
-
'User-Agent': 'Strav-Social',
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const [userResponse, emailsResponse] = await Promise.all([
|
|
28
|
-
fetch('https://api.github.com/user', { headers }),
|
|
29
|
-
fetch('https://api.github.com/user/emails', { headers }),
|
|
30
|
-
])
|
|
31
|
-
|
|
32
|
-
if (!userResponse.ok) {
|
|
33
|
-
throw new ExternalServiceError(
|
|
34
|
-
'GitHub',
|
|
35
|
-
userResponse.status,
|
|
36
|
-
scrubProviderError(await userResponse.text())
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const user = (await userResponse.json()) as Record<string, unknown>
|
|
41
|
-
|
|
42
|
-
// If the user's profile email is private, fall back to the primary verified email
|
|
43
|
-
if (!user.email && emailsResponse.ok) {
|
|
44
|
-
const emails = (await emailsResponse.json()) as Array<{
|
|
45
|
-
email: string
|
|
46
|
-
primary: boolean
|
|
47
|
-
verified: boolean
|
|
48
|
-
}>
|
|
49
|
-
const primary = emails.find(e => e.primary && e.verified)
|
|
50
|
-
if (primary) user.email = primary.email
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return user
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
57
|
-
const email = (data.email as string) ?? null
|
|
58
|
-
return {
|
|
59
|
-
id: String(data.id),
|
|
60
|
-
name: (data.name as string) ?? null,
|
|
61
|
-
email,
|
|
62
|
-
// GitHub only exposes verified emails: the profile `email` is required
|
|
63
|
-
// to be a verified address, and the fallback path in getUserByToken
|
|
64
|
-
// filters /user/emails for `verified === true`.
|
|
65
|
-
emailVerified: email !== null,
|
|
66
|
-
avatar: (data.avatar_url as string) ?? null,
|
|
67
|
-
nickname: (data.login as string) ?? null,
|
|
68
|
-
token: '',
|
|
69
|
-
refreshToken: null,
|
|
70
|
-
expiresIn: null,
|
|
71
|
-
approvedScopes: [],
|
|
72
|
-
raw: data,
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -1,51 +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 GoogleProvider extends AbstractProvider {
|
|
6
|
-
readonly name = 'Google'
|
|
7
|
-
|
|
8
|
-
protected getDefaultScopes(): string[] {
|
|
9
|
-
return ['openid', 'email', 'profile']
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
protected getAuthUrl(): string {
|
|
13
|
-
return 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
protected getTokenUrl(): string {
|
|
17
|
-
return 'https://oauth2.googleapis.com/token'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
-
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
|
22
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
throw new ExternalServiceError(
|
|
27
|
-
'Google',
|
|
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
|
-
return {
|
|
38
|
-
id: data.sub as string,
|
|
39
|
-
name: (data.name as string) ?? null,
|
|
40
|
-
email: (data.email as string) ?? null,
|
|
41
|
-
emailVerified: data.email_verified === true,
|
|
42
|
-
avatar: (data.picture as string) ?? null,
|
|
43
|
-
nickname: null,
|
|
44
|
-
token: '',
|
|
45
|
-
refreshToken: null,
|
|
46
|
-
expiresIn: null,
|
|
47
|
-
approvedScopes: [],
|
|
48
|
-
raw: data,
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|