@stravigor/socialite 0.1.1 → 0.2.1
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 +3 -3
- package/src/helpers.ts +19 -1
- package/src/index.ts +5 -0
- package/src/schema.ts +13 -0
- package/src/social_account.ts +184 -0
- package/src/socialite_manager.ts +25 -1
- package/src/socialite_provider.ts +16 -0
- package/src/types.ts +1 -0
- package/stubs/config/socialite.ts +22 -0
- package/stubs/schemas/social_account.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravigor/socialite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAuth social authentication for the Strav framework",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./*": "./src/*.ts"
|
|
10
10
|
},
|
|
11
|
-
"files": ["src/", "package.json", "tsconfig.json", "CHANGELOG.md"],
|
|
11
|
+
"files": ["src/", "stubs/", "package.json", "tsconfig.json", "CHANGELOG.md"],
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@stravigor/core": "0.2.
|
|
13
|
+
"@stravigor/core": "0.2.6"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"test": "bun test tests/",
|
package/src/helpers.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AbstractProvider } from './abstract_provider.ts'
|
|
2
|
+
import SocialAccount from './social_account.ts'
|
|
2
3
|
import SocialiteManager from './socialite_manager.ts'
|
|
3
|
-
import type { ProviderConfig } from './types.ts'
|
|
4
|
+
import type { ProviderConfig, SocialiteUser } from './types.ts'
|
|
5
|
+
import type { SocialAccountData } from './social_account.ts'
|
|
4
6
|
|
|
5
7
|
export const socialite = {
|
|
6
8
|
driver(name: string): AbstractProvider {
|
|
@@ -10,4 +12,20 @@ export const socialite = {
|
|
|
10
12
|
extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
|
|
11
13
|
SocialiteManager.extend(name, factory)
|
|
12
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 socialite.driver('google').user(ctx)
|
|
22
|
+
* const { account, created } = await socialite.findOrCreate('google', socialUser, user)
|
|
23
|
+
*/
|
|
24
|
+
findOrCreate(
|
|
25
|
+
provider: string,
|
|
26
|
+
socialUser: SocialiteUser,
|
|
27
|
+
user: unknown
|
|
28
|
+
): Promise<{ account: SocialAccountData; created: boolean }> {
|
|
29
|
+
return SocialAccount.findOrCreate(provider, socialUser, user)
|
|
30
|
+
},
|
|
13
31
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { default, default as SocialiteManager } from './socialite_manager.ts'
|
|
2
|
+
|
|
3
|
+
// Provider
|
|
4
|
+
export { default as SocialiteProvider } from './socialite_provider.ts'
|
|
5
|
+
export { default as SocialAccount } from './social_account.ts'
|
|
2
6
|
export { socialite } from './helpers.ts'
|
|
3
7
|
export { AbstractProvider, SocialiteError } from './abstract_provider.ts'
|
|
4
8
|
export { GoogleProvider } from './providers/google_provider.ts'
|
|
@@ -7,3 +11,4 @@ export { DiscordProvider } from './providers/discord_provider.ts'
|
|
|
7
11
|
export { FacebookProvider } from './providers/facebook_provider.ts'
|
|
8
12
|
export { LinkedInProvider } from './providers/linkedin_provider.ts'
|
|
9
13
|
export type { SocialiteUser, SocialiteConfig, ProviderConfig, TokenResponse } from './types.ts'
|
|
14
|
+
export type { SocialAccountData } from './social_account.ts'
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineSchema, t, Archetype } from '@stravigor/core/schema'
|
|
2
|
+
|
|
3
|
+
export const schema = defineSchema('social_account', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parent: '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/core/helpers'
|
|
2
|
+
import SocialiteManager from './socialite_manager.ts'
|
|
3
|
+
import type { SocialiteUser } 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 (SocialiteManager.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 SocialiteManager.db.sql
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static get fk() {
|
|
35
|
+
return SocialiteManager.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: SocialiteUser,
|
|
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
|
+
}
|
package/src/socialite_manager.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { inject } from '@stravigor/core/core'
|
|
2
2
|
import type Configuration from '@stravigor/core/config/configuration'
|
|
3
|
+
import type Database from '@stravigor/core/database/database'
|
|
3
4
|
import { ConfigurationError } from '@stravigor/core/exceptions/errors'
|
|
5
|
+
import { toSnakeCase } from '@stravigor/core/schema'
|
|
4
6
|
import type { AbstractProvider } from './abstract_provider.ts'
|
|
5
7
|
import type { ProviderConfig, SocialiteConfig } from './types.ts'
|
|
6
8
|
import { GoogleProvider } from './providers/google_provider.ts'
|
|
@@ -11,15 +13,32 @@ import { LinkedInProvider } from './providers/linkedin_provider.ts'
|
|
|
11
13
|
|
|
12
14
|
@inject
|
|
13
15
|
export default class SocialiteManager {
|
|
16
|
+
private static _db: Database
|
|
14
17
|
private static _config: SocialiteConfig
|
|
18
|
+
private static _userFkColumn: string
|
|
15
19
|
private static _extensions = new Map<string, (config: ProviderConfig) => AbstractProvider>()
|
|
16
20
|
|
|
17
|
-
constructor(config: Configuration) {
|
|
21
|
+
constructor(db: Database, config: Configuration) {
|
|
22
|
+
SocialiteManager._db = db
|
|
23
|
+
|
|
24
|
+
const userKey = config.get('socialite.userKey', 'id') as string
|
|
25
|
+
SocialiteManager._userFkColumn = `user_${toSnakeCase(userKey)}`
|
|
26
|
+
|
|
18
27
|
SocialiteManager._config = {
|
|
28
|
+
userKey,
|
|
19
29
|
providers: config.get('socialite.providers', {}) as Record<string, ProviderConfig>,
|
|
20
30
|
}
|
|
21
31
|
}
|
|
22
32
|
|
|
33
|
+
static get db(): Database {
|
|
34
|
+
if (!SocialiteManager._db) {
|
|
35
|
+
throw new ConfigurationError(
|
|
36
|
+
'SocialiteManager not configured. Resolve it through the container first.'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return SocialiteManager._db
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
static get config(): SocialiteConfig {
|
|
24
43
|
if (!SocialiteManager._config) {
|
|
25
44
|
throw new ConfigurationError(
|
|
@@ -29,6 +48,11 @@ export default class SocialiteManager {
|
|
|
29
48
|
return SocialiteManager._config
|
|
30
49
|
}
|
|
31
50
|
|
|
51
|
+
/** The FK column name on the social_account table (e.g. `user_id`, `user_uid`). */
|
|
52
|
+
static get userFkColumn(): string {
|
|
53
|
+
return SocialiteManager._userFkColumn ?? 'user_id'
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
/**
|
|
33
57
|
* Get a fresh provider instance by name.
|
|
34
58
|
* Returns a new instance each call because fluent methods mutate state.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/core/core'
|
|
2
|
+
import type { Application } from '@stravigor/core/core'
|
|
3
|
+
import SocialiteManager from './socialite_manager.ts'
|
|
4
|
+
|
|
5
|
+
export default class SocialiteProvider extends ServiceProvider {
|
|
6
|
+
readonly name = 'socialite'
|
|
7
|
+
override readonly dependencies = ['database']
|
|
8
|
+
|
|
9
|
+
override register(app: Application): void {
|
|
10
|
+
app.singleton(SocialiteManager)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override boot(app: Application): void {
|
|
14
|
+
app.resolve(SocialiteManager)
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { env } from '@stravigor/core/helpers'
|
|
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/core/schema'
|
|
2
|
+
|
|
3
|
+
export default defineSchema('social_account', {
|
|
4
|
+
archetype: Archetype.Component,
|
|
5
|
+
parent: '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
|
+
})
|