@strav/social 0.1.0
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/CHANGELOG.md +19 -0
- package/README.md +79 -0
- package/package.json +27 -0
- package/src/abstract_provider.ts +171 -0
- package/src/helpers.ts +31 -0
- package/src/index.ts +14 -0
- package/src/providers/discord_provider.ts +54 -0
- package/src/providers/facebook_provider.ts +51 -0
- package/src/providers/github_provider.ts +66 -0
- package/src/providers/google_provider.ts +46 -0
- package/src/providers/linkedin_provider.ts +46 -0
- package/src/schema.ts +13 -0
- package/src/social_account.ts +184 -0
- package/src/social_manager.ts +95 -0
- package/src/social_provider.ts +16 -0
- package/src/types.ts +32 -0
- package/stubs/config/social.ts +22 -0
- package/stubs/schemas/social_account.ts +13 -0
- package/tsconfig.json +5 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @stravigor/social
|
|
2
|
+
|
|
3
|
+
OAuth social authentication for the [Strav](https://www.npmjs.com/package/@stravigor/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 @stravigor/social
|
|
9
|
+
bun strav install social
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { SocialProvider } from '@stravigor/social'
|
|
18
|
+
|
|
19
|
+
app.use(new SocialProvider())
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { social } from '@stravigor/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
|
+
social.driver('google').stateless().redirect(ctx)
|
|
61
|
+
const user = await social.driver('google').userFromToken(accessToken)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Custom Providers
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { AbstractProvider, social } from '@stravigor/social'
|
|
68
|
+
|
|
69
|
+
class SpotifyProvider extends AbstractProvider { /* ... */ }
|
|
70
|
+
social.extend('spotify', config => new SpotifyProvider(config))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Documentation
|
|
74
|
+
|
|
75
|
+
See the full [Social guide](../../guides/social.md).
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/social",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OAuth social authentication for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"stubs/",
|
|
14
|
+
"package.json",
|
|
15
|
+
"tsconfig.json",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@strav/kernel": "0.1.0",
|
|
20
|
+
"@strav/http": "0.1.0",
|
|
21
|
+
"@strav/database": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test tests/",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Context, Session } from '@stravigor/http'
|
|
2
|
+
import { randomHex, ExternalServiceError } from '@stravigor/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 _stateless = false
|
|
13
|
+
protected _parameters: Record<string, string> = {}
|
|
14
|
+
|
|
15
|
+
constructor(config: ProviderConfig) {
|
|
16
|
+
this.config = config
|
|
17
|
+
this._scopes = config.scopes ?? this.getDefaultScopes()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Template methods — each provider implements these
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
protected abstract getDefaultScopes(): string[]
|
|
25
|
+
protected abstract getAuthUrl(): string
|
|
26
|
+
protected abstract getTokenUrl(): string
|
|
27
|
+
protected abstract getUserByToken(token: string): Promise<Record<string, unknown>>
|
|
28
|
+
protected abstract mapUserToObject(data: Record<string, unknown>): SocialUser
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Fluent API
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
scopes(scopes: string[]): this {
|
|
35
|
+
this._scopes = [...new Set([...this._scopes, ...scopes])]
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setScopes(scopes: string[]): this {
|
|
40
|
+
this._scopes = scopes
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
stateless(): this {
|
|
45
|
+
this._stateless = true
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
with(params: Record<string, string>): this {
|
|
50
|
+
this._parameters = { ...this._parameters, ...params }
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// OAuth flow
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
redirect(ctx: Context): Response {
|
|
59
|
+
let state: string | undefined
|
|
60
|
+
|
|
61
|
+
if (!this._stateless) {
|
|
62
|
+
state = randomHex(32)
|
|
63
|
+
const session = ctx.get<Session>('session')
|
|
64
|
+
session.set(STATE_KEY, state)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const url = this.buildAuthUrl(state)
|
|
68
|
+
return ctx.redirect(url)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async user(ctx: Context): Promise<SocialUser> {
|
|
72
|
+
if (!this._stateless) {
|
|
73
|
+
const session = ctx.get<Session>('session')
|
|
74
|
+
const expectedState = session.get<string>(STATE_KEY)
|
|
75
|
+
const returnedState = ctx.query.get('state')
|
|
76
|
+
|
|
77
|
+
if (!expectedState || expectedState !== returnedState) {
|
|
78
|
+
throw new SocialError('Invalid state parameter. Possible CSRF attack.')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
session.forget(STATE_KEY)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const code = ctx.query.get('code')
|
|
85
|
+
if (!code) {
|
|
86
|
+
const error = ctx.query.get('error')
|
|
87
|
+
throw new SocialError(error ? `OAuth error: ${error}` : 'Missing authorization code.')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const token = await this.getAccessToken(code)
|
|
91
|
+
const data = await this.getUserByToken(token.accessToken)
|
|
92
|
+
const user = this.mapUserToObject(data)
|
|
93
|
+
|
|
94
|
+
user.token = token.accessToken
|
|
95
|
+
user.refreshToken = token.refreshToken
|
|
96
|
+
user.expiresIn = token.expiresIn
|
|
97
|
+
user.approvedScopes = token.scope ? token.scope.split(/[\s,]+/) : this._scopes
|
|
98
|
+
user.raw = data
|
|
99
|
+
|
|
100
|
+
return user
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async userFromToken(token: string): Promise<SocialUser> {
|
|
104
|
+
const data = await this.getUserByToken(token)
|
|
105
|
+
const user = this.mapUserToObject(data)
|
|
106
|
+
|
|
107
|
+
user.token = token
|
|
108
|
+
user.refreshToken = null
|
|
109
|
+
user.expiresIn = null
|
|
110
|
+
user.approvedScopes = this._scopes
|
|
111
|
+
user.raw = data
|
|
112
|
+
|
|
113
|
+
return user
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Internal
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
protected async getAccessToken(code: string): Promise<TokenResponse> {
|
|
121
|
+
const response = await fetch(this.getTokenUrl(), {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
125
|
+
Accept: 'application/json',
|
|
126
|
+
},
|
|
127
|
+
body: new URLSearchParams({
|
|
128
|
+
grant_type: 'authorization_code',
|
|
129
|
+
client_id: this.config.clientId,
|
|
130
|
+
client_secret: this.config.clientSecret,
|
|
131
|
+
code,
|
|
132
|
+
redirect_uri: this.config.redirectUrl,
|
|
133
|
+
}),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
const text = await response.text()
|
|
138
|
+
throw new ExternalServiceError(this.name, response.status, text)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = (await response.json()) as Record<string, unknown>
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
accessToken: data.access_token as string,
|
|
145
|
+
refreshToken: (data.refresh_token as string) ?? null,
|
|
146
|
+
expiresIn: (data.expires_in as number) ?? null,
|
|
147
|
+
scope: (data.scope as string) ?? null,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
protected buildAuthUrl(state?: string): string {
|
|
152
|
+
const params = new URLSearchParams({
|
|
153
|
+
client_id: this.config.clientId,
|
|
154
|
+
redirect_uri: this.config.redirectUrl,
|
|
155
|
+
response_type: 'code',
|
|
156
|
+
scope: this._scopes.join(' '),
|
|
157
|
+
...this._parameters,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (state) params.set('state', state)
|
|
161
|
+
|
|
162
|
+
return `${this.getAuthUrl()}?${params.toString()}`
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class SocialError extends Error {
|
|
167
|
+
constructor(message: string) {
|
|
168
|
+
super(message)
|
|
169
|
+
this.name = 'SocialError'
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { default, default as SocialManager } from './social_manager.ts'
|
|
2
|
+
|
|
3
|
+
// Provider
|
|
4
|
+
export { default as SocialProvider } from './social_provider.ts'
|
|
5
|
+
export { default as SocialAccount } from './social_account.ts'
|
|
6
|
+
export { social } from './helpers.ts'
|
|
7
|
+
export { AbstractProvider, SocialError } from './abstract_provider.ts'
|
|
8
|
+
export { GoogleProvider } from './providers/google_provider.ts'
|
|
9
|
+
export { GitHubProvider } from './providers/github_provider.ts'
|
|
10
|
+
export { DiscordProvider } from './providers/discord_provider.ts'
|
|
11
|
+
export { FacebookProvider } from './providers/facebook_provider.ts'
|
|
12
|
+
export { LinkedInProvider } from './providers/linkedin_provider.ts'
|
|
13
|
+
export type { SocialUser, SocialConfig, ProviderConfig, TokenResponse } from './types.ts'
|
|
14
|
+
export type { SocialAccountData } from './social_account.ts'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/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('Discord', response.status, await response.text())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (await response.json()) as Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
33
|
+
let avatar: string | null = null
|
|
34
|
+
if (data.avatar) {
|
|
35
|
+
avatar = `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png`
|
|
36
|
+
} else if (data.id) {
|
|
37
|
+
const index = Number((BigInt(data.id as string) >> 22n) % 6n)
|
|
38
|
+
avatar = `https://cdn.discordapp.com/embed/avatars/${index}.png`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: data.id as string,
|
|
43
|
+
name: (data.global_name as string) ?? null,
|
|
44
|
+
email: (data.email as string) ?? null,
|
|
45
|
+
avatar,
|
|
46
|
+
nickname: (data.username as string) ?? null,
|
|
47
|
+
token: '',
|
|
48
|
+
refreshToken: null,
|
|
49
|
+
expiresIn: null,
|
|
50
|
+
approvedScopes: [],
|
|
51
|
+
raw: data,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/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
|
+
protected getAuthUrl(): string {
|
|
15
|
+
return `https://www.facebook.com/${API_VERSION}/dialog/oauth`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
protected getTokenUrl(): string {
|
|
19
|
+
return `https://graph.facebook.com/${API_VERSION}/oauth/access_token`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
23
|
+
const fields = 'id,name,email,picture.type(large)'
|
|
24
|
+
const response = await fetch(
|
|
25
|
+
`https://graph.facebook.com/${API_VERSION}/me?fields=${fields}&access_token=${token}`
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new ExternalServiceError('Facebook', response.status, await response.text())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (await response.json()) as Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
36
|
+
const picture = data.picture as { data?: { url?: string } } | undefined
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: data.id as string,
|
|
40
|
+
name: (data.name as string) ?? null,
|
|
41
|
+
email: (data.email as string) ?? null,
|
|
42
|
+
avatar: picture?.data?.url ?? null,
|
|
43
|
+
nickname: null,
|
|
44
|
+
token: '',
|
|
45
|
+
refreshToken: null,
|
|
46
|
+
expiresIn: null,
|
|
47
|
+
approvedScopes: [],
|
|
48
|
+
raw: data,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/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('GitHub', userResponse.status, await userResponse.text())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const user = (await userResponse.json()) as Record<string, unknown>
|
|
37
|
+
|
|
38
|
+
// If the user's profile email is private, fall back to the primary verified email
|
|
39
|
+
if (!user.email && emailsResponse.ok) {
|
|
40
|
+
const emails = (await emailsResponse.json()) as Array<{
|
|
41
|
+
email: string
|
|
42
|
+
primary: boolean
|
|
43
|
+
verified: boolean
|
|
44
|
+
}>
|
|
45
|
+
const primary = emails.find(e => e.primary && e.verified)
|
|
46
|
+
if (primary) user.email = primary.email
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return user
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
53
|
+
return {
|
|
54
|
+
id: String(data.id),
|
|
55
|
+
name: (data.name as string) ?? null,
|
|
56
|
+
email: (data.email as string) ?? null,
|
|
57
|
+
avatar: (data.avatar_url as string) ?? null,
|
|
58
|
+
nickname: (data.login as string) ?? null,
|
|
59
|
+
token: '',
|
|
60
|
+
refreshToken: null,
|
|
61
|
+
expiresIn: null,
|
|
62
|
+
approvedScopes: [],
|
|
63
|
+
raw: data,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/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('Google', response.status, await response.text())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (await response.json()) as Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
33
|
+
return {
|
|
34
|
+
id: data.sub as string,
|
|
35
|
+
name: (data.name as string) ?? null,
|
|
36
|
+
email: (data.email as string) ?? null,
|
|
37
|
+
avatar: (data.picture as string) ?? null,
|
|
38
|
+
nickname: null,
|
|
39
|
+
token: '',
|
|
40
|
+
refreshToken: null,
|
|
41
|
+
expiresIn: null,
|
|
42
|
+
approvedScopes: [],
|
|
43
|
+
raw: data,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/kernel'
|
|
2
|
+
import { AbstractProvider } from '../abstract_provider.ts'
|
|
3
|
+
import type { SocialUser } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
export class LinkedInProvider extends AbstractProvider {
|
|
6
|
+
readonly name = 'LinkedIn'
|
|
7
|
+
|
|
8
|
+
protected getDefaultScopes(): string[] {
|
|
9
|
+
return ['openid', 'profile', 'email']
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
protected getAuthUrl(): string {
|
|
13
|
+
return 'https://www.linkedin.com/oauth/v2/authorization'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
protected getTokenUrl(): string {
|
|
17
|
+
return 'https://www.linkedin.com/oauth/v2/accessToken'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected async getUserByToken(token: string): Promise<Record<string, unknown>> {
|
|
21
|
+
const response = await fetch('https://api.linkedin.com/v2/userinfo', {
|
|
22
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new ExternalServiceError('LinkedIn', response.status, await response.text())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (await response.json()) as Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected mapUserToObject(data: Record<string, unknown>): SocialUser {
|
|
33
|
+
return {
|
|
34
|
+
id: data.sub as string,
|
|
35
|
+
name: (data.name as string) ?? null,
|
|
36
|
+
email: (data.email as string) ?? null,
|
|
37
|
+
avatar: (data.picture as string) ?? null,
|
|
38
|
+
nickname: null,
|
|
39
|
+
token: '',
|
|
40
|
+
refreshToken: null,
|
|
41
|
+
expiresIn: null,
|
|
42
|
+
approvedScopes: [],
|
|
43
|
+
raw: data,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/database'
|
|
2
|
+
|
|
3
|
+
export const schema = defineSchema('social_account', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['user'],
|
|
6
|
+
fields: {
|
|
7
|
+
provider: t.varchar(50).required().index(),
|
|
8
|
+
providerId: t.varchar(255).required().index(),
|
|
9
|
+
token: t.text().required().sensitive(),
|
|
10
|
+
refreshToken: t.text().nullable().sensitive(),
|
|
11
|
+
expiresAt: t.timestamptz().nullable(),
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { extractUserId } from '@stravigor/database'
|
|
2
|
+
import SocialManager from './social_manager.ts'
|
|
3
|
+
import type { SocialUser } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/** The DB record for a social account link. */
|
|
6
|
+
export interface SocialAccountData {
|
|
7
|
+
id: number
|
|
8
|
+
userId: string | number
|
|
9
|
+
provider: string
|
|
10
|
+
providerId: string
|
|
11
|
+
token: string
|
|
12
|
+
refreshToken: string | null
|
|
13
|
+
expiresAt: Date | null
|
|
14
|
+
createdAt: Date
|
|
15
|
+
updatedAt: Date
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Static helper for managing social account records.
|
|
20
|
+
*
|
|
21
|
+
* Follows the same pattern as AccessToken: all methods are static,
|
|
22
|
+
* database access goes through the parent manager (SocialManager.db).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const account = await SocialAccount.findByProvider('github', '12345')
|
|
26
|
+
* const accounts = await SocialAccount.findByUser(user)
|
|
27
|
+
* const created = await SocialAccount.create({ user, provider: 'google', ... })
|
|
28
|
+
*/
|
|
29
|
+
export default class SocialAccount {
|
|
30
|
+
private static get sql() {
|
|
31
|
+
return SocialManager.db.sql
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static get fk() {
|
|
35
|
+
return SocialManager.userFkColumn
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find a social account by provider name and provider-specific user ID.
|
|
40
|
+
* This is the primary lookup used during OAuth callback.
|
|
41
|
+
*/
|
|
42
|
+
static async findByProvider(
|
|
43
|
+
provider: string,
|
|
44
|
+
providerId: string
|
|
45
|
+
): Promise<SocialAccountData | null> {
|
|
46
|
+
const rows = await SocialAccount.sql`
|
|
47
|
+
SELECT * FROM "social_account"
|
|
48
|
+
WHERE "provider" = ${provider}
|
|
49
|
+
AND "provider_id" = ${providerId}
|
|
50
|
+
LIMIT 1
|
|
51
|
+
`
|
|
52
|
+
return rows.length > 0 ? SocialAccount.hydrate(rows[0] as Record<string, unknown>) : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find all social accounts linked to a user.
|
|
57
|
+
*/
|
|
58
|
+
static async findByUser(user: unknown): Promise<SocialAccountData[]> {
|
|
59
|
+
const userId = extractUserId(user)
|
|
60
|
+
const fk = SocialAccount.fk
|
|
61
|
+
const rows = await SocialAccount.sql.unsafe(
|
|
62
|
+
`SELECT * FROM "social_account" WHERE "${fk}" = $1 ORDER BY "created_at" ASC`,
|
|
63
|
+
[userId]
|
|
64
|
+
)
|
|
65
|
+
return rows.map((r: any) => SocialAccount.hydrate(r))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a new social account link.
|
|
70
|
+
*/
|
|
71
|
+
static async create(data: {
|
|
72
|
+
user: unknown
|
|
73
|
+
provider: string
|
|
74
|
+
providerId: string
|
|
75
|
+
token: string
|
|
76
|
+
refreshToken?: string | null
|
|
77
|
+
expiresAt?: Date | null
|
|
78
|
+
}): Promise<SocialAccountData> {
|
|
79
|
+
const userId = extractUserId(data.user)
|
|
80
|
+
const fk = SocialAccount.fk
|
|
81
|
+
const rows = await SocialAccount.sql.unsafe(
|
|
82
|
+
`INSERT INTO "social_account" ("${fk}", "provider", "provider_id", "token", "refresh_token", "expires_at")
|
|
83
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
84
|
+
RETURNING *`,
|
|
85
|
+
[
|
|
86
|
+
userId,
|
|
87
|
+
data.provider,
|
|
88
|
+
data.providerId,
|
|
89
|
+
data.token,
|
|
90
|
+
data.refreshToken ?? null,
|
|
91
|
+
data.expiresAt ?? null,
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
return SocialAccount.hydrate(rows[0] as Record<string, unknown>)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find an existing social account by provider or create a new one.
|
|
99
|
+
* If the account already exists, its tokens are updated.
|
|
100
|
+
*/
|
|
101
|
+
static async findOrCreate(
|
|
102
|
+
provider: string,
|
|
103
|
+
socialUser: SocialUser,
|
|
104
|
+
user: unknown
|
|
105
|
+
): Promise<{ account: SocialAccountData; created: boolean }> {
|
|
106
|
+
const existing = await SocialAccount.findByProvider(provider, socialUser.id)
|
|
107
|
+
if (existing) {
|
|
108
|
+
await SocialAccount.updateTokens(
|
|
109
|
+
existing.id,
|
|
110
|
+
socialUser.token,
|
|
111
|
+
socialUser.refreshToken,
|
|
112
|
+
socialUser.expiresIn ? new Date(Date.now() + socialUser.expiresIn * 1000) : null
|
|
113
|
+
)
|
|
114
|
+
existing.token = socialUser.token
|
|
115
|
+
existing.refreshToken = socialUser.refreshToken
|
|
116
|
+
existing.expiresAt = socialUser.expiresIn
|
|
117
|
+
? new Date(Date.now() + socialUser.expiresIn * 1000)
|
|
118
|
+
: null
|
|
119
|
+
return { account: existing, created: false }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const account = await SocialAccount.create({
|
|
123
|
+
user,
|
|
124
|
+
provider,
|
|
125
|
+
providerId: socialUser.id,
|
|
126
|
+
token: socialUser.token,
|
|
127
|
+
refreshToken: socialUser.refreshToken,
|
|
128
|
+
expiresAt: socialUser.expiresIn ? new Date(Date.now() + socialUser.expiresIn * 1000) : null,
|
|
129
|
+
})
|
|
130
|
+
return { account, created: true }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Update OAuth tokens for an existing social account.
|
|
135
|
+
*/
|
|
136
|
+
static async updateTokens(
|
|
137
|
+
id: number,
|
|
138
|
+
token: string,
|
|
139
|
+
refreshToken: string | null,
|
|
140
|
+
expiresAt: Date | null
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
await SocialAccount.sql`
|
|
143
|
+
UPDATE "social_account"
|
|
144
|
+
SET "token" = ${token},
|
|
145
|
+
"refresh_token" = ${refreshToken},
|
|
146
|
+
"expires_at" = ${expiresAt},
|
|
147
|
+
"updated_at" = NOW()
|
|
148
|
+
WHERE "id" = ${id}
|
|
149
|
+
`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Delete a social account by its database ID. */
|
|
153
|
+
static async delete(id: number): Promise<void> {
|
|
154
|
+
await SocialAccount.sql`
|
|
155
|
+
DELETE FROM "social_account" WHERE "id" = ${id}
|
|
156
|
+
`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Delete all social accounts for a user. */
|
|
160
|
+
static async deleteByUser(user: unknown): Promise<void> {
|
|
161
|
+
const userId = extractUserId(user)
|
|
162
|
+
const fk = SocialAccount.fk
|
|
163
|
+
await SocialAccount.sql.unsafe(`DELETE FROM "social_account" WHERE "${fk}" = $1`, [userId])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Internal
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
private static hydrate(row: Record<string, unknown>): SocialAccountData {
|
|
171
|
+
const fk = SocialAccount.fk
|
|
172
|
+
return {
|
|
173
|
+
id: row.id as number,
|
|
174
|
+
userId: row[fk] as string | number,
|
|
175
|
+
provider: row.provider as string,
|
|
176
|
+
providerId: row.provider_id as string,
|
|
177
|
+
token: row.token as string,
|
|
178
|
+
refreshToken: (row.refresh_token as string) ?? null,
|
|
179
|
+
expiresAt: (row.expires_at as Date) ?? null,
|
|
180
|
+
createdAt: row.created_at as Date,
|
|
181
|
+
updatedAt: row.updated_at as Date,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { inject, Configuration, ConfigurationError } from '@stravigor/kernel'
|
|
2
|
+
import { Database, toSnakeCase } from '@stravigor/database'
|
|
3
|
+
import type { AbstractProvider } from './abstract_provider.ts'
|
|
4
|
+
import type { ProviderConfig, SocialConfig } from './types.ts'
|
|
5
|
+
import { GoogleProvider } from './providers/google_provider.ts'
|
|
6
|
+
import { GitHubProvider } from './providers/github_provider.ts'
|
|
7
|
+
import { DiscordProvider } from './providers/discord_provider.ts'
|
|
8
|
+
import { FacebookProvider } from './providers/facebook_provider.ts'
|
|
9
|
+
import { LinkedInProvider } from './providers/linkedin_provider.ts'
|
|
10
|
+
|
|
11
|
+
@inject
|
|
12
|
+
export default class SocialManager {
|
|
13
|
+
private static _db: Database
|
|
14
|
+
private static _config: SocialConfig
|
|
15
|
+
private static _userFkColumn: string
|
|
16
|
+
private static _extensions = new Map<string, (config: ProviderConfig) => AbstractProvider>()
|
|
17
|
+
|
|
18
|
+
constructor(db: Database, config: Configuration) {
|
|
19
|
+
SocialManager._db = db
|
|
20
|
+
|
|
21
|
+
const userKey = config.get('social.userKey', 'id') as string
|
|
22
|
+
SocialManager._userFkColumn = `user_${toSnakeCase(userKey)}`
|
|
23
|
+
|
|
24
|
+
SocialManager._config = {
|
|
25
|
+
userKey,
|
|
26
|
+
providers: config.get('social.providers', {}) as Record<string, ProviderConfig>,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static get db(): Database {
|
|
31
|
+
if (!SocialManager._db) {
|
|
32
|
+
throw new ConfigurationError(
|
|
33
|
+
'SocialManager not configured. Resolve it through the container first.'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return SocialManager._db
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static get config(): SocialConfig {
|
|
40
|
+
if (!SocialManager._config) {
|
|
41
|
+
throw new ConfigurationError(
|
|
42
|
+
'SocialManager not configured. Resolve it through the container first.'
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
return SocialManager._config
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The FK column name on the social_account table (e.g. `user_id`, `user_uid`). */
|
|
49
|
+
static get userFkColumn(): string {
|
|
50
|
+
return SocialManager._userFkColumn ?? 'user_id'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get a fresh provider instance by name.
|
|
55
|
+
* Returns a new instance each call because fluent methods mutate state.
|
|
56
|
+
*/
|
|
57
|
+
static driver(name: string): AbstractProvider {
|
|
58
|
+
const providerConfig = SocialManager._config?.providers[name]
|
|
59
|
+
if (!providerConfig) {
|
|
60
|
+
throw new ConfigurationError(`Social provider "${name}" is not configured.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const driverName = providerConfig.driver ?? name
|
|
64
|
+
|
|
65
|
+
const extension = SocialManager._extensions.get(driverName)
|
|
66
|
+
if (extension) return extension(providerConfig)
|
|
67
|
+
|
|
68
|
+
switch (driverName) {
|
|
69
|
+
case 'google':
|
|
70
|
+
return new GoogleProvider(providerConfig)
|
|
71
|
+
case 'github':
|
|
72
|
+
return new GitHubProvider(providerConfig)
|
|
73
|
+
case 'discord':
|
|
74
|
+
return new DiscordProvider(providerConfig)
|
|
75
|
+
case 'facebook':
|
|
76
|
+
return new FacebookProvider(providerConfig)
|
|
77
|
+
case 'linkedin':
|
|
78
|
+
return new LinkedInProvider(providerConfig)
|
|
79
|
+
default:
|
|
80
|
+
throw new ConfigurationError(
|
|
81
|
+
`Unknown social driver "${driverName}". Register it with SocialManager.extend().`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Register a custom provider factory. */
|
|
87
|
+
static extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
|
|
88
|
+
SocialManager._extensions.set(name, factory)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Clear all custom extensions (useful for testing). */
|
|
92
|
+
static reset(): void {
|
|
93
|
+
SocialManager._extensions.clear()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/kernel'
|
|
2
|
+
import type { Application } from '@stravigor/kernel'
|
|
3
|
+
import SocialManager from './social_manager.ts'
|
|
4
|
+
|
|
5
|
+
export default class SocialProvider extends ServiceProvider {
|
|
6
|
+
readonly name = 'social'
|
|
7
|
+
override readonly dependencies = ['database']
|
|
8
|
+
|
|
9
|
+
override register(app: Application): void {
|
|
10
|
+
app.singleton(SocialManager)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override boot(app: Application): void {
|
|
14
|
+
app.resolve(SocialManager)
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface SocialUser {
|
|
2
|
+
id: string
|
|
3
|
+
name: string | null
|
|
4
|
+
email: string | null
|
|
5
|
+
avatar: string | null
|
|
6
|
+
nickname: string | null
|
|
7
|
+
token: string
|
|
8
|
+
refreshToken: string | null
|
|
9
|
+
expiresIn: number | null
|
|
10
|
+
approvedScopes: string[]
|
|
11
|
+
raw: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProviderConfig {
|
|
15
|
+
driver?: string
|
|
16
|
+
clientId: string
|
|
17
|
+
clientSecret: string
|
|
18
|
+
redirectUrl: string
|
|
19
|
+
scopes?: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SocialConfig {
|
|
23
|
+
userKey: string
|
|
24
|
+
providers: Record<string, ProviderConfig>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TokenResponse {
|
|
28
|
+
accessToken: string
|
|
29
|
+
refreshToken: string | null
|
|
30
|
+
expiresIn: number | null
|
|
31
|
+
scope: string | null
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { env } from '@stravigor/kernel'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
userKey: 'id',
|
|
5
|
+
providers: {
|
|
6
|
+
// google: {
|
|
7
|
+
// clientId: env('GOOGLE_CLIENT_ID', ''),
|
|
8
|
+
// clientSecret: env('GOOGLE_CLIENT_SECRET', ''),
|
|
9
|
+
// redirectUrl: env('GOOGLE_REDIRECT_URL', 'http://localhost:3000/auth/google/callback'),
|
|
10
|
+
// },
|
|
11
|
+
// github: {
|
|
12
|
+
// clientId: env('GITHUB_CLIENT_ID', ''),
|
|
13
|
+
// clientSecret: env('GITHUB_CLIENT_SECRET', ''),
|
|
14
|
+
// redirectUrl: env('GITHUB_REDIRECT_URL', 'http://localhost:3000/auth/github/callback'),
|
|
15
|
+
// },
|
|
16
|
+
// discord: {
|
|
17
|
+
// clientId: env('DISCORD_CLIENT_ID', ''),
|
|
18
|
+
// clientSecret: env('DISCORD_CLIENT_SECRET', ''),
|
|
19
|
+
// redirectUrl: env('DISCORD_REDIRECT_URL', 'http://localhost:3000/auth/discord/callback'),
|
|
20
|
+
// },
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/database'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('social_account', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parents: ['user'],
|
|
6
|
+
fields: {
|
|
7
|
+
provider: t.varchar(50).required().index(),
|
|
8
|
+
providerId: t.varchar(255).required().index(),
|
|
9
|
+
token: t.text().required().sensitive(),
|
|
10
|
+
refreshToken: t.text().nullable().sensitive(),
|
|
11
|
+
expiresAt: t.timestamptz().nullable(),
|
|
12
|
+
},
|
|
13
|
+
})
|