@stravigor/socialite 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,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ ### Added
6
+
7
+ - Initial release with OAuth 2.0 social authentication
8
+ - Built-in providers: Google, GitHub, Discord
9
+ - `SocialiteManager` with driver-based architecture and custom provider extensibility
10
+ - Session-based CSRF state verification with `stateless()` opt-out
11
+ - `socialite` helper for fluent API access
12
+ - `AbstractProvider` base class for building custom providers
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@stravigor/socialite",
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": ["src/", "package.json", "tsconfig.json", "CHANGELOG.md"],
12
+ "peerDependencies": {
13
+ "@stravigor/core": "0.2.2"
14
+ },
15
+ "scripts": {
16
+ "test": "bun test tests/",
17
+ "typecheck": "tsc --noEmit"
18
+ }
19
+ }
@@ -0,0 +1,173 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import type Session from '@stravigor/core/session/session'
3
+ import { randomHex } from '@stravigor/core/helpers/crypto'
4
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
5
+ import type { ProviderConfig, SocialiteUser, TokenResponse } from './types.ts'
6
+
7
+ const STATE_KEY = 'socialite_state'
8
+
9
+ export abstract class AbstractProvider {
10
+ abstract readonly name: string
11
+
12
+ protected config: ProviderConfig
13
+ protected _scopes: string[]
14
+ protected _stateless = false
15
+ protected _parameters: Record<string, string> = {}
16
+
17
+ constructor(config: ProviderConfig) {
18
+ this.config = config
19
+ this._scopes = config.scopes ?? this.getDefaultScopes()
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Template methods — each provider implements these
24
+ // ---------------------------------------------------------------------------
25
+
26
+ protected abstract getDefaultScopes(): string[]
27
+ protected abstract getAuthUrl(): string
28
+ protected abstract getTokenUrl(): string
29
+ protected abstract getUserByToken(token: string): Promise<Record<string, unknown>>
30
+ protected abstract mapUserToObject(data: Record<string, unknown>): SocialiteUser
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Fluent API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ scopes(scopes: string[]): this {
37
+ this._scopes = [...new Set([...this._scopes, ...scopes])]
38
+ return this
39
+ }
40
+
41
+ setScopes(scopes: string[]): this {
42
+ this._scopes = scopes
43
+ return this
44
+ }
45
+
46
+ stateless(): this {
47
+ this._stateless = true
48
+ return this
49
+ }
50
+
51
+ with(params: Record<string, string>): this {
52
+ this._parameters = { ...this._parameters, ...params }
53
+ return this
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // OAuth flow
58
+ // ---------------------------------------------------------------------------
59
+
60
+ redirect(ctx: Context): Response {
61
+ let state: string | undefined
62
+
63
+ if (!this._stateless) {
64
+ state = randomHex(32)
65
+ const session = ctx.get<Session>('session')
66
+ session.set(STATE_KEY, state)
67
+ }
68
+
69
+ const url = this.buildAuthUrl(state)
70
+ return ctx.redirect(url)
71
+ }
72
+
73
+ async user(ctx: Context): Promise<SocialiteUser> {
74
+ if (!this._stateless) {
75
+ const session = ctx.get<Session>('session')
76
+ const expectedState = session.get<string>(STATE_KEY)
77
+ const returnedState = ctx.query.get('state')
78
+
79
+ if (!expectedState || expectedState !== returnedState) {
80
+ throw new SocialiteError('Invalid state parameter. Possible CSRF attack.')
81
+ }
82
+
83
+ session.forget(STATE_KEY)
84
+ }
85
+
86
+ const code = ctx.query.get('code')
87
+ if (!code) {
88
+ const error = ctx.query.get('error')
89
+ throw new SocialiteError(error ? `OAuth error: ${error}` : 'Missing authorization code.')
90
+ }
91
+
92
+ const token = await this.getAccessToken(code)
93
+ const data = await this.getUserByToken(token.accessToken)
94
+ const user = this.mapUserToObject(data)
95
+
96
+ user.token = token.accessToken
97
+ user.refreshToken = token.refreshToken
98
+ user.expiresIn = token.expiresIn
99
+ user.approvedScopes = token.scope ? token.scope.split(/[\s,]+/) : this._scopes
100
+ user.raw = data
101
+
102
+ return user
103
+ }
104
+
105
+ async userFromToken(token: string): Promise<SocialiteUser> {
106
+ const data = await this.getUserByToken(token)
107
+ const user = this.mapUserToObject(data)
108
+
109
+ user.token = token
110
+ user.refreshToken = null
111
+ user.expiresIn = null
112
+ user.approvedScopes = this._scopes
113
+ user.raw = data
114
+
115
+ return user
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Internal
120
+ // ---------------------------------------------------------------------------
121
+
122
+ protected async getAccessToken(code: string): Promise<TokenResponse> {
123
+ const response = await fetch(this.getTokenUrl(), {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/x-www-form-urlencoded',
127
+ Accept: 'application/json',
128
+ },
129
+ body: new URLSearchParams({
130
+ grant_type: 'authorization_code',
131
+ client_id: this.config.clientId,
132
+ client_secret: this.config.clientSecret,
133
+ code,
134
+ redirect_uri: this.config.redirectUrl,
135
+ }),
136
+ })
137
+
138
+ if (!response.ok) {
139
+ const text = await response.text()
140
+ throw new ExternalServiceError(this.name, response.status, text)
141
+ }
142
+
143
+ const data = (await response.json()) as Record<string, unknown>
144
+
145
+ return {
146
+ accessToken: data.access_token as string,
147
+ refreshToken: (data.refresh_token as string) ?? null,
148
+ expiresIn: (data.expires_in as number) ?? null,
149
+ scope: (data.scope as string) ?? null,
150
+ }
151
+ }
152
+
153
+ protected buildAuthUrl(state?: string): string {
154
+ const params = new URLSearchParams({
155
+ client_id: this.config.clientId,
156
+ redirect_uri: this.config.redirectUrl,
157
+ response_type: 'code',
158
+ scope: this._scopes.join(' '),
159
+ ...this._parameters,
160
+ })
161
+
162
+ if (state) params.set('state', state)
163
+
164
+ return `${this.getAuthUrl()}?${params.toString()}`
165
+ }
166
+ }
167
+
168
+ export class SocialiteError extends Error {
169
+ constructor(message: string) {
170
+ super(message)
171
+ this.name = 'SocialiteError'
172
+ }
173
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { AbstractProvider } from './abstract_provider.ts'
2
+ import SocialiteManager from './socialite_manager.ts'
3
+ import type { ProviderConfig } from './types.ts'
4
+
5
+ export const socialite = {
6
+ driver(name: string): AbstractProvider {
7
+ return SocialiteManager.driver(name)
8
+ },
9
+
10
+ extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
11
+ SocialiteManager.extend(name, factory)
12
+ },
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { default, default as SocialiteManager } from './socialite_manager.ts'
2
+ export { socialite } from './helpers.ts'
3
+ export { AbstractProvider, SocialiteError } from './abstract_provider.ts'
4
+ export { GoogleProvider } from './providers/google_provider.ts'
5
+ export { GitHubProvider } from './providers/github_provider.ts'
6
+ export { DiscordProvider } from './providers/discord_provider.ts'
7
+ export type { SocialiteUser, SocialiteConfig, ProviderConfig, TokenResponse } from './types.ts'
@@ -0,0 +1,54 @@
1
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
2
+ import { AbstractProvider } from '../abstract_provider.ts'
3
+ import type { SocialiteUser } 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>): SocialiteUser {
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,66 @@
1
+ import { ExternalServiceError } from '@stravigor/core/exceptions/errors'
2
+ import { AbstractProvider } from '../abstract_provider.ts'
3
+ import type { SocialiteUser } 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-Socialite',
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>): SocialiteUser {
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/core/exceptions/errors'
2
+ import { AbstractProvider } from '../abstract_provider.ts'
3
+ import type { SocialiteUser } 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>): SocialiteUser {
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,68 @@
1
+ import { inject } from '@stravigor/core/core'
2
+ import type Configuration from '@stravigor/core/config/configuration'
3
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
4
+ import type { AbstractProvider } from './abstract_provider.ts'
5
+ import type { ProviderConfig, SocialiteConfig } from './types.ts'
6
+ import { GoogleProvider } from './providers/google_provider.ts'
7
+ import { GitHubProvider } from './providers/github_provider.ts'
8
+ import { DiscordProvider } from './providers/discord_provider.ts'
9
+
10
+ @inject
11
+ export default class SocialiteManager {
12
+ private static _config: SocialiteConfig
13
+ private static _extensions = new Map<string, (config: ProviderConfig) => AbstractProvider>()
14
+
15
+ constructor(config: Configuration) {
16
+ SocialiteManager._config = {
17
+ providers: config.get('socialite.providers', {}) as Record<string, ProviderConfig>,
18
+ }
19
+ }
20
+
21
+ static get config(): SocialiteConfig {
22
+ if (!SocialiteManager._config) {
23
+ throw new ConfigurationError(
24
+ 'SocialiteManager not configured. Resolve it through the container first.'
25
+ )
26
+ }
27
+ return SocialiteManager._config
28
+ }
29
+
30
+ /**
31
+ * Get a fresh provider instance by name.
32
+ * Returns a new instance each call because fluent methods mutate state.
33
+ */
34
+ static driver(name: string): AbstractProvider {
35
+ const providerConfig = SocialiteManager._config?.providers[name]
36
+ if (!providerConfig) {
37
+ throw new ConfigurationError(`Socialite provider "${name}" is not configured.`)
38
+ }
39
+
40
+ const driverName = providerConfig.driver ?? name
41
+
42
+ const extension = SocialiteManager._extensions.get(driverName)
43
+ if (extension) return extension(providerConfig)
44
+
45
+ switch (driverName) {
46
+ case 'google':
47
+ return new GoogleProvider(providerConfig)
48
+ case 'github':
49
+ return new GitHubProvider(providerConfig)
50
+ case 'discord':
51
+ return new DiscordProvider(providerConfig)
52
+ default:
53
+ throw new ConfigurationError(
54
+ `Unknown socialite driver "${driverName}". Register it with SocialiteManager.extend().`
55
+ )
56
+ }
57
+ }
58
+
59
+ /** Register a custom provider factory. */
60
+ static extend(name: string, factory: (config: ProviderConfig) => AbstractProvider): void {
61
+ SocialiteManager._extensions.set(name, factory)
62
+ }
63
+
64
+ /** Clear all custom extensions (useful for testing). */
65
+ static reset(): void {
66
+ SocialiteManager._extensions.clear()
67
+ }
68
+ }
package/src/types.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface SocialiteUser {
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 SocialiteConfig {
23
+ providers: Record<string, ProviderConfig>
24
+ }
25
+
26
+ export interface TokenResponse {
27
+ accessToken: string
28
+ refreshToken: string | null
29
+ expiresIn: number | null
30
+ scope: string | null
31
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }