@stravigor/socialite 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravigor/socialite",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "OAuth social authentication for the Strav framework",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
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
13
  "@stravigor/core": "0.2.2"
14
14
  },
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,5 @@
1
1
  export { default, default as SocialiteManager } from './socialite_manager.ts'
2
+ export { default as SocialAccount } from './social_account.ts'
2
3
  export { socialite } from './helpers.ts'
3
4
  export { AbstractProvider, SocialiteError } from './abstract_provider.ts'
4
5
  export { GoogleProvider } from './providers/google_provider.ts'
@@ -7,3 +8,4 @@ export { DiscordProvider } from './providers/discord_provider.ts'
7
8
  export { FacebookProvider } from './providers/facebook_provider.ts'
8
9
  export { LinkedInProvider } from './providers/linkedin_provider.ts'
9
10
  export type { SocialiteUser, SocialiteConfig, ProviderConfig, TokenResponse } from './types.ts'
11
+ 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
+ }
@@ -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.
package/src/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface ProviderConfig {
20
20
  }
21
21
 
22
22
  export interface SocialiteConfig {
23
+ userKey: string
23
24
  providers: Record<string, ProviderConfig>
24
25
  }
25
26
 
@@ -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
+ })