@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 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
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }