@mantiq/social-auth 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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@mantiq/social-auth",
3
+ "version": "0.1.0",
4
+ "description": "Social authentication — login with Google, GitHub, Facebook, Apple, and more via extensible providers",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/mantiqjs/mantiq/tree/main/packages/social-auth",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mantiqjs/mantiq.git",
12
+ "directory": "packages/social-auth"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/mantiqjs/mantiq/issues"
16
+ },
17
+ "keywords": ["mantiq", "social-auth", "oauth", "google", "github", "facebook", "apple", "login"],
18
+ "engines": { "bun": ">=1.1.0" },
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "exports": { ".": { "bun": "./src/index.ts", "default": "./src/index.ts" } },
22
+ "files": ["src/", "package.json", "README.md"],
23
+ "scripts": {
24
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "peerDependencies": {
30
+ "@mantiq/core": "^0.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "bun-types": "latest",
34
+ "typescript": "^5.7.0",
35
+ "@mantiq/core": "workspace:*"
36
+ }
37
+ }
@@ -0,0 +1,131 @@
1
+ import type { OAuthProvider } from './contracts/OAuthProvider.ts'
2
+ import type { OAuthUser } from './contracts/OAuthUser.ts'
3
+
4
+ export interface ProviderConfig {
5
+ clientId: string
6
+ clientSecret: string
7
+ redirectUrl: string
8
+ }
9
+
10
+ /**
11
+ * Base class implementing the OAuth 2.0 authorization code flow.
12
+ *
13
+ * Subclasses must implement:
14
+ * - `getAuthUrl()` — the provider's authorization endpoint
15
+ * - `getTokenUrl()` — the provider's token exchange endpoint
16
+ * - `getUserByToken(token)` — fetch the raw user profile from the provider
17
+ * - `mapUserToObject(raw)` — normalize the raw profile into an OAuthUser
18
+ */
19
+ export abstract class AbstractProvider implements OAuthProvider {
20
+ abstract readonly name: string
21
+
22
+ protected clientId: string
23
+ protected clientSecret: string
24
+ protected redirectUrl: string
25
+ protected _scopes: string[] = []
26
+ protected _params: Record<string, string> = {}
27
+ protected _stateless = false
28
+
29
+ constructor(config: ProviderConfig) {
30
+ this.clientId = config.clientId
31
+ this.clientSecret = config.clientSecret
32
+ this.redirectUrl = config.redirectUrl
33
+ }
34
+
35
+ // ── Abstract (provider-specific) ─────────────────────────────────────────
36
+
37
+ protected abstract getAuthUrl(): string
38
+ protected abstract getTokenUrl(): string
39
+ protected abstract getUserByToken(token: string): Promise<Record<string, any>>
40
+ protected abstract mapUserToObject(raw: Record<string, any>): OAuthUser
41
+
42
+ // ── OAuth 2.0 flow ───────────────────────────────────────────────────────
43
+
44
+ redirect(): Response {
45
+ const url = new URL(this.getAuthUrl())
46
+ url.searchParams.set('client_id', this.clientId)
47
+ url.searchParams.set('redirect_uri', this.redirectUrl)
48
+ url.searchParams.set('response_type', 'code')
49
+ url.searchParams.set('scope', this._scopes.join(' '))
50
+
51
+ if (!this._stateless) {
52
+ const state = crypto.randomUUID()
53
+ url.searchParams.set('state', state)
54
+ }
55
+
56
+ for (const [k, v] of Object.entries(this._params)) {
57
+ url.searchParams.set(k, v)
58
+ }
59
+
60
+ return Response.redirect(url.toString(), 302)
61
+ }
62
+
63
+ async user(request: any): Promise<OAuthUser> {
64
+ const code = typeof request?.query === 'function'
65
+ ? request.query('code')
66
+ : new URL(request.url).searchParams.get('code')
67
+
68
+ if (!code) {
69
+ throw new Error('Authorization code not found in callback')
70
+ }
71
+
72
+ const tokenData = await this.getAccessToken(code)
73
+ const rawUser = await this.getUserByToken(tokenData.access_token)
74
+ const oauthUser = this.mapUserToObject(rawUser)
75
+ oauthUser.token = tokenData.access_token
76
+ oauthUser.refreshToken = tokenData.refresh_token ?? null
77
+ oauthUser.expiresIn = tokenData.expires_in ?? null
78
+ return oauthUser
79
+ }
80
+
81
+ async userFromToken(accessToken: string): Promise<OAuthUser> {
82
+ const raw = await this.getUserByToken(accessToken)
83
+ const user = this.mapUserToObject(raw)
84
+ user.token = accessToken
85
+ return user
86
+ }
87
+
88
+ // ── Token exchange ───────────────────────────────────────────────────────
89
+
90
+ protected async getAccessToken(
91
+ code: string,
92
+ ): Promise<{ access_token: string; refresh_token?: string; expires_in?: number }> {
93
+ const res = await fetch(this.getTokenUrl(), {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/x-www-form-urlencoded',
97
+ Accept: 'application/json',
98
+ },
99
+ body: new URLSearchParams({
100
+ grant_type: 'authorization_code',
101
+ client_id: this.clientId,
102
+ client_secret: this.clientSecret,
103
+ code,
104
+ redirect_uri: this.redirectUrl,
105
+ }),
106
+ })
107
+
108
+ if (!res.ok) {
109
+ throw new Error(`Token exchange failed: ${res.status}`)
110
+ }
111
+
112
+ return res.json() as Promise<{ access_token: string; refresh_token?: string; expires_in?: number }>
113
+ }
114
+
115
+ // ── Fluent configuration ─────────────────────────────────────────────────
116
+
117
+ scopes(scopes: string[]): this {
118
+ this._scopes = scopes
119
+ return this
120
+ }
121
+
122
+ with(params: Record<string, string>): this {
123
+ Object.assign(this._params, params)
124
+ return this
125
+ }
126
+
127
+ stateless(): this {
128
+ this._stateless = true
129
+ return this
130
+ }
131
+ }
@@ -0,0 +1,110 @@
1
+ import type { OAuthProvider } from './contracts/OAuthProvider.ts'
2
+ import type { ProviderConfig } from './AbstractProvider.ts'
3
+ import { GoogleProvider } from './providers/GoogleProvider.ts'
4
+ import { GitHubProvider } from './providers/GitHubProvider.ts'
5
+ import { FacebookProvider } from './providers/FacebookProvider.ts'
6
+ import { AppleProvider } from './providers/AppleProvider.ts'
7
+ import { TwitterProvider } from './providers/TwitterProvider.ts'
8
+ import { LinkedInProvider } from './providers/LinkedInProvider.ts'
9
+ import { MicrosoftProvider } from './providers/MicrosoftProvider.ts'
10
+ import { DiscordProvider } from './providers/DiscordProvider.ts'
11
+
12
+ export interface SocialAuthConfig {
13
+ [provider: string]: ProviderConfig
14
+ }
15
+
16
+ /**
17
+ * Manager for social authentication providers.
18
+ *
19
+ * Lazily instantiates providers on first access and caches them.
20
+ * Supports all 8 built-in providers and custom providers via `extend()`.
21
+ *
22
+ * @example
23
+ * const manager = new SocialAuthManager(config)
24
+ * const github = manager.driver('github')
25
+ * return github.redirect()
26
+ */
27
+ export class SocialAuthManager {
28
+ private readonly instances = new Map<string, OAuthProvider>()
29
+ private readonly customCreators = new Map<string, (config: ProviderConfig) => OAuthProvider>()
30
+
31
+ /** Built-in provider name → constructor mapping. */
32
+ private static readonly builtInProviders: Record<
33
+ string,
34
+ new (config: ProviderConfig) => OAuthProvider
35
+ > = {
36
+ google: GoogleProvider,
37
+ github: GitHubProvider,
38
+ facebook: FacebookProvider,
39
+ apple: AppleProvider,
40
+ twitter: TwitterProvider,
41
+ linkedin: LinkedInProvider,
42
+ microsoft: MicrosoftProvider,
43
+ discord: DiscordProvider,
44
+ }
45
+
46
+ constructor(private readonly config: SocialAuthConfig) {}
47
+
48
+ /**
49
+ * Get a provider instance by name (lazy init + cache).
50
+ */
51
+ driver(name: string): OAuthProvider {
52
+ if (!this.instances.has(name)) {
53
+ this.instances.set(name, this.createDriver(name))
54
+ }
55
+
56
+ return this.instances.get(name)!
57
+ }
58
+
59
+ /**
60
+ * Register a custom provider factory.
61
+ * Overrides built-in providers if the name matches.
62
+ */
63
+ extend(name: string, factory: (config: ProviderConfig) => OAuthProvider): void {
64
+ this.customCreators.set(name, factory)
65
+ // Clear cached instance so the new factory takes effect
66
+ this.instances.delete(name)
67
+ }
68
+
69
+ /**
70
+ * List all available provider names (built-in + custom + configured).
71
+ */
72
+ getProviders(): string[] {
73
+ const names = new Set<string>([
74
+ ...Object.keys(SocialAuthManager.builtInProviders),
75
+ ...this.customCreators.keys(),
76
+ ...Object.keys(this.config),
77
+ ])
78
+ return [...names]
79
+ }
80
+
81
+ // ── Internal ────────────────────────────────────────────────────────────
82
+
83
+ private createDriver(name: string): OAuthProvider {
84
+ // Custom creators take precedence — they may not need config
85
+ const custom = this.customCreators.get(name)
86
+ if (custom) {
87
+ const providerConfig = this.config[name] ?? {}
88
+ return custom(providerConfig as ProviderConfig)
89
+ }
90
+
91
+ const providerConfig = this.config[name]
92
+ if (!providerConfig) {
93
+ throw new Error(
94
+ `Social auth provider "${name}" is not configured. ` +
95
+ `Add it to your social-auth config file.`,
96
+ )
97
+ }
98
+
99
+ // Built-in providers
100
+ const ProviderClass = SocialAuthManager.builtInProviders[name]
101
+ if (ProviderClass) {
102
+ return new ProviderClass(providerConfig)
103
+ }
104
+
105
+ throw new Error(
106
+ `Unknown social auth provider "${name}". ` +
107
+ `Use extend() to register custom providers.`,
108
+ )
109
+ }
110
+ }
@@ -0,0 +1,38 @@
1
+ import { ServiceProvider } from '@mantiq/core'
2
+ import { SocialAuthManager } from './SocialAuthManager.ts'
3
+ import type { SocialAuthConfig } from './SocialAuthManager.ts'
4
+ import { setSocialAuthManager } from './helpers/social-auth.ts'
5
+ import { ConfigRepository } from '@mantiq/core'
6
+
7
+ /**
8
+ * Registers the SocialAuthManager in the container and sets the global helper.
9
+ *
10
+ * Reads configuration from `config/social-auth.ts` (the `social-auth` config key).
11
+ *
12
+ * @example config/social-auth.ts
13
+ * export default {
14
+ * github: {
15
+ * clientId: env('GITHUB_CLIENT_ID'),
16
+ * clientSecret: env('GITHUB_CLIENT_SECRET'),
17
+ * redirectUrl: env('GITHUB_REDIRECT_URL'),
18
+ * },
19
+ * google: {
20
+ * clientId: env('GOOGLE_CLIENT_ID'),
21
+ * clientSecret: env('GOOGLE_CLIENT_SECRET'),
22
+ * redirectUrl: env('GOOGLE_REDIRECT_URL'),
23
+ * },
24
+ * }
25
+ */
26
+ export class SocialAuthServiceProvider extends ServiceProvider {
27
+ override register(): void {
28
+ const configRepo = this.app.make(ConfigRepository)
29
+ const socialConfig = configRepo.get<SocialAuthConfig>('social-auth', {})
30
+
31
+ this.app.singleton(SocialAuthManager, () => {
32
+ return new SocialAuthManager(socialConfig)
33
+ })
34
+
35
+ // Set the global helper so socialAuth() works outside the container
36
+ setSocialAuthManager(this.app.make(SocialAuthManager))
37
+ }
38
+ }
@@ -0,0 +1,11 @@
1
+ import type { OAuthUser } from './OAuthUser.ts'
2
+
3
+ export interface OAuthProvider {
4
+ readonly name: string
5
+ redirect(): Response
6
+ user(request: any): Promise<OAuthUser>
7
+ userFromToken(accessToken: string): Promise<OAuthUser>
8
+ scopes(scopes: string[]): this
9
+ with(params: Record<string, string>): this
10
+ stateless(): this
11
+ }
@@ -0,0 +1,10 @@
1
+ export interface OAuthUser {
2
+ id: string
3
+ name: string | null
4
+ email: string | null
5
+ avatar: string | null
6
+ token: string
7
+ refreshToken: string | null
8
+ expiresIn: number | null
9
+ raw: Record<string, any>
10
+ }
@@ -0,0 +1,32 @@
1
+ import type { SocialAuthManager } from '../SocialAuthManager.ts'
2
+
3
+ let _manager: SocialAuthManager | null = null
4
+
5
+ /**
6
+ * Set the global SocialAuthManager instance.
7
+ * Called by SocialAuthServiceProvider during registration.
8
+ */
9
+ export function setSocialAuthManager(manager: SocialAuthManager): void {
10
+ _manager = manager
11
+ }
12
+
13
+ /**
14
+ * Access the global SocialAuthManager from anywhere.
15
+ *
16
+ * @example
17
+ * const github = socialAuth().driver('github')
18
+ * return github.redirect()
19
+ *
20
+ * @example
21
+ * const user = await socialAuth().driver('github').user(request)
22
+ */
23
+ export function socialAuth(): SocialAuthManager {
24
+ if (!_manager) {
25
+ throw new Error(
26
+ 'SocialAuthManager has not been initialized. ' +
27
+ 'Register SocialAuthServiceProvider in your application.',
28
+ )
29
+ }
30
+
31
+ return _manager
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // ── Contracts ────────────────────────────────────────────────────────────────
2
+ export type { OAuthProvider } from './contracts/OAuthProvider.ts'
3
+ export type { OAuthUser } from './contracts/OAuthUser.ts'
4
+
5
+ // ── Abstract base ────────────────────────────────────────────────────────────
6
+ export { AbstractProvider } from './AbstractProvider.ts'
7
+ export type { ProviderConfig } from './AbstractProvider.ts'
8
+
9
+ // ── Built-in providers ───────────────────────────────────────────────────────
10
+ export { GoogleProvider } from './providers/GoogleProvider.ts'
11
+ export { GitHubProvider } from './providers/GitHubProvider.ts'
12
+ export { FacebookProvider } from './providers/FacebookProvider.ts'
13
+ export { AppleProvider } from './providers/AppleProvider.ts'
14
+ export { TwitterProvider } from './providers/TwitterProvider.ts'
15
+ export { LinkedInProvider } from './providers/LinkedInProvider.ts'
16
+ export { MicrosoftProvider } from './providers/MicrosoftProvider.ts'
17
+ export { DiscordProvider } from './providers/DiscordProvider.ts'
18
+
19
+ // ── Manager ──────────────────────────────────────────────────────────────────
20
+ export { SocialAuthManager } from './SocialAuthManager.ts'
21
+ export type { SocialAuthConfig } from './SocialAuthManager.ts'
22
+
23
+ // ── Service provider ─────────────────────────────────────────────────────────
24
+ export { SocialAuthServiceProvider } from './SocialAuthServiceProvider.ts'
25
+
26
+ // ── Global helper ────────────────────────────────────────────────────────────
27
+ export { socialAuth, setSocialAuthManager } from './helpers/social-auth.ts'
@@ -0,0 +1,144 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Apple Sign In provider.
6
+ *
7
+ * Apple is unique among OAuth providers:
8
+ * - User info is embedded in the `id_token` JWT (there is no userinfo endpoint)
9
+ * - The callback uses `response_mode=form_post` (POST with form data)
10
+ * - The user's name is only sent on the FIRST authorization; subsequent logins
11
+ * only include the id_token with sub + email
12
+ */
13
+ export class AppleProvider extends AbstractProvider {
14
+ override readonly name = 'apple'
15
+
16
+ protected override _scopes: string[] = ['name', 'email']
17
+ protected override _params: Record<string, string> = {
18
+ response_mode: 'form_post',
19
+ }
20
+
21
+ protected override getAuthUrl(): string {
22
+ return 'https://appleid.apple.com/auth/authorize'
23
+ }
24
+
25
+ protected override getTokenUrl(): string {
26
+ return 'https://appleid.apple.com/auth/token'
27
+ }
28
+
29
+ /**
30
+ * Apple does not have a userinfo endpoint. User details come from the
31
+ * id_token JWT returned during the token exchange. We decode the JWT
32
+ * payload without verification (the token was just received over TLS
33
+ * from Apple's server).
34
+ */
35
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
36
+ // The "token" here is actually the id_token from the token response.
37
+ // We store the full token response in _lastTokenResponse so we can
38
+ // decode the id_token.
39
+ return this.decodeIdToken(token)
40
+ }
41
+
42
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
43
+ return {
44
+ id: String(raw.sub),
45
+ name: raw.name ?? null,
46
+ email: raw.email ?? null,
47
+ avatar: null, // Apple does not provide avatar URLs
48
+ token: '',
49
+ refreshToken: null,
50
+ expiresIn: null,
51
+ raw,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Override the user() method to extract user info from the id_token
57
+ * rather than calling a userinfo endpoint.
58
+ */
59
+ override async user(request: any): Promise<OAuthUser> {
60
+ const code = typeof request?.query === 'function'
61
+ ? request.query('code')
62
+ : this.extractCode(request)
63
+
64
+ if (!code) {
65
+ throw new Error('Authorization code not found in callback')
66
+ }
67
+
68
+ const tokenData = await this.getAccessToken(code)
69
+
70
+ // Apple returns an id_token alongside the access_token
71
+ const idToken = (tokenData as any).id_token
72
+ if (!idToken) {
73
+ throw new Error('Apple did not return an id_token')
74
+ }
75
+
76
+ const claims = this.decodeIdToken(idToken)
77
+
78
+ // On first authorization, Apple sends user info in the POST body
79
+ const userName = this.extractUserName(request)
80
+ if (userName) {
81
+ claims.name = userName
82
+ }
83
+
84
+ const oauthUser = this.mapUserToObject(claims)
85
+ oauthUser.token = tokenData.access_token
86
+ oauthUser.refreshToken = tokenData.refresh_token ?? null
87
+ oauthUser.expiresIn = tokenData.expires_in ?? null
88
+ return oauthUser
89
+ }
90
+
91
+ /**
92
+ * Decode a JWT id_token payload without signature verification.
93
+ * Apple's id_token contains: sub, email, email_verified, iss, aud, exp, iat.
94
+ */
95
+ private decodeIdToken(idToken: string): Record<string, any> {
96
+ const parts = idToken.split('.')
97
+ if (parts.length !== 3) {
98
+ throw new Error('Invalid id_token format')
99
+ }
100
+
101
+ const payload = parts[1]!
102
+ const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
103
+ return JSON.parse(decoded)
104
+ }
105
+
106
+ /**
107
+ * Extract the authorization code from a form_post callback.
108
+ */
109
+ private extractCode(request: any): string | null {
110
+ // Try URL search params first (GET)
111
+ try {
112
+ const url = new URL(request.url)
113
+ const code = url.searchParams.get('code')
114
+ if (code) return code
115
+ } catch {
116
+ // not a valid URL, ignore
117
+ }
118
+
119
+ // Try form body (POST with form_post response_mode)
120
+ if (request.body?.code) return request.body.code
121
+ if (request.formData?.code) return request.formData.code
122
+
123
+ return null
124
+ }
125
+
126
+ /**
127
+ * Apple sends the user's name only on first authorization, embedded in the
128
+ * POST body as a JSON string in the `user` field.
129
+ */
130
+ private extractUserName(request: any): string | null {
131
+ try {
132
+ const userJson = request.body?.user ?? request.formData?.user
133
+ if (!userJson) return null
134
+
135
+ const userData = typeof userJson === 'string' ? JSON.parse(userJson) : userJson
136
+ const firstName = userData.name?.firstName ?? ''
137
+ const lastName = userData.name?.lastName ?? ''
138
+ const fullName = `${firstName} ${lastName}`.trim()
139
+ return fullName || null
140
+ } catch {
141
+ return null
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,51 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Discord OAuth 2.0 provider.
6
+ *
7
+ * Fetches user info from Discord's /users/@me endpoint.
8
+ * Constructs avatar URL from the user's id and avatar hash.
9
+ */
10
+ export class DiscordProvider extends AbstractProvider {
11
+ override readonly name = 'discord'
12
+
13
+ protected override _scopes: string[] = ['identify', 'email']
14
+
15
+ protected override getAuthUrl(): string {
16
+ return 'https://discord.com/api/oauth2/authorize'
17
+ }
18
+
19
+ protected override getTokenUrl(): string {
20
+ return 'https://discord.com/api/oauth2/token'
21
+ }
22
+
23
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
24
+ const res = await fetch('https://discord.com/api/users/@me', {
25
+ headers: { Authorization: `Bearer ${token}` },
26
+ })
27
+
28
+ if (!res.ok) {
29
+ throw new Error(`Failed to fetch Discord user: ${res.status}`)
30
+ }
31
+
32
+ return res.json() as Promise<Record<string, any>>
33
+ }
34
+
35
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
36
+ const avatarUrl = raw.avatar
37
+ ? `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png`
38
+ : null
39
+
40
+ return {
41
+ id: String(raw.id),
42
+ name: raw.username ?? null,
43
+ email: raw.email ?? null,
44
+ avatar: avatarUrl,
45
+ token: '',
46
+ refreshToken: null,
47
+ expiresIn: null,
48
+ raw,
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,47 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Facebook OAuth 2.0 provider.
6
+ *
7
+ * Uses the Graph API v18.0 to fetch user info.
8
+ */
9
+ export class FacebookProvider extends AbstractProvider {
10
+ override readonly name = 'facebook'
11
+
12
+ protected override _scopes: string[] = ['email']
13
+
14
+ protected override getAuthUrl(): string {
15
+ return 'https://www.facebook.com/v18.0/dialog/oauth'
16
+ }
17
+
18
+ protected override getTokenUrl(): string {
19
+ return 'https://graph.facebook.com/v18.0/oauth/access_token'
20
+ }
21
+
22
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
23
+ const url = 'https://graph.facebook.com/v18.0/me?fields=id,name,email,picture.type(large)'
24
+ const res = await fetch(url, {
25
+ headers: { Authorization: `Bearer ${token}` },
26
+ })
27
+
28
+ if (!res.ok) {
29
+ throw new Error(`Failed to fetch Facebook user: ${res.status}`)
30
+ }
31
+
32
+ return res.json() as Promise<Record<string, any>>
33
+ }
34
+
35
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
36
+ return {
37
+ id: String(raw.id),
38
+ name: raw.name ?? null,
39
+ email: raw.email ?? null,
40
+ avatar: raw.picture?.data?.url ?? null,
41
+ token: '',
42
+ refreshToken: null,
43
+ expiresIn: null,
44
+ raw,
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,80 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * GitHub OAuth 2.0 provider.
6
+ *
7
+ * GitHub may not return the user's email in the main /user response if the
8
+ * email is set to private. In that case, we fetch GET /user/emails and pick
9
+ * the primary verified email.
10
+ */
11
+ export class GitHubProvider extends AbstractProvider {
12
+ override readonly name = 'github'
13
+
14
+ protected override _scopes: string[] = ['user:email']
15
+
16
+ protected override getAuthUrl(): string {
17
+ return 'https://github.com/login/oauth/authorize'
18
+ }
19
+
20
+ protected override getTokenUrl(): string {
21
+ return 'https://github.com/login/oauth/access_token'
22
+ }
23
+
24
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
25
+ const res = await fetch('https://api.github.com/user', {
26
+ headers: {
27
+ Authorization: `Bearer ${token}`,
28
+ Accept: 'application/json',
29
+ },
30
+ })
31
+
32
+ if (!res.ok) {
33
+ throw new Error(`Failed to fetch GitHub user: ${res.status}`)
34
+ }
35
+
36
+ const user = (await res.json()) as Record<string, any>
37
+
38
+ // GitHub may not include email if it's set to private — fetch from /user/emails
39
+ if (!user.email) {
40
+ user.email = await this.fetchPrimaryEmail(token)
41
+ }
42
+
43
+ return user
44
+ }
45
+
46
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
47
+ return {
48
+ id: String(raw.id),
49
+ name: raw.name ?? null,
50
+ email: raw.email ?? null,
51
+ avatar: raw.avatar_url ?? null,
52
+ token: '',
53
+ refreshToken: null,
54
+ expiresIn: null,
55
+ raw,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Fetch the user's primary verified email from the /user/emails endpoint.
61
+ */
62
+ private async fetchPrimaryEmail(token: string): Promise<string | null> {
63
+ try {
64
+ const res = await fetch('https://api.github.com/user/emails', {
65
+ headers: {
66
+ Authorization: `Bearer ${token}`,
67
+ Accept: 'application/json',
68
+ },
69
+ })
70
+
71
+ if (!res.ok) return null
72
+
73
+ const emails = (await res.json()) as Array<{ email: string; primary: boolean; verified: boolean }>
74
+ const primary = emails.find((e) => e.primary && e.verified)
75
+ return primary?.email ?? emails[0]?.email ?? null
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,47 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Google OAuth 2.0 provider.
6
+ *
7
+ * Uses the Google Identity v2 userinfo endpoint.
8
+ * Default scopes request OpenID Connect profile + email.
9
+ */
10
+ export class GoogleProvider extends AbstractProvider {
11
+ override readonly name = 'google'
12
+
13
+ protected override _scopes: string[] = ['openid', 'email', 'profile']
14
+
15
+ protected override getAuthUrl(): string {
16
+ return 'https://accounts.google.com/o/oauth2/v2/auth'
17
+ }
18
+
19
+ protected override getTokenUrl(): string {
20
+ return 'https://oauth2.googleapis.com/token'
21
+ }
22
+
23
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
24
+ const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
25
+ headers: { Authorization: `Bearer ${token}` },
26
+ })
27
+
28
+ if (!res.ok) {
29
+ throw new Error(`Failed to fetch Google user: ${res.status}`)
30
+ }
31
+
32
+ return res.json() as Promise<Record<string, any>>
33
+ }
34
+
35
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
36
+ return {
37
+ id: String(raw.sub ?? raw.id),
38
+ name: raw.name ?? null,
39
+ email: raw.email ?? null,
40
+ avatar: raw.picture ?? null,
41
+ token: '',
42
+ refreshToken: null,
43
+ expiresIn: null,
44
+ raw,
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,46 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * LinkedIn OAuth 2.0 provider.
6
+ *
7
+ * Uses the OpenID Connect userinfo endpoint (v2 API).
8
+ */
9
+ export class LinkedInProvider extends AbstractProvider {
10
+ override readonly name = 'linkedin'
11
+
12
+ protected override _scopes: string[] = ['openid', 'profile', 'email']
13
+
14
+ protected override getAuthUrl(): string {
15
+ return 'https://www.linkedin.com/oauth/v2/authorization'
16
+ }
17
+
18
+ protected override getTokenUrl(): string {
19
+ return 'https://www.linkedin.com/oauth/v2/accessToken'
20
+ }
21
+
22
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
23
+ const res = await fetch('https://api.linkedin.com/v2/userinfo', {
24
+ headers: { Authorization: `Bearer ${token}` },
25
+ })
26
+
27
+ if (!res.ok) {
28
+ throw new Error(`Failed to fetch LinkedIn user: ${res.status}`)
29
+ }
30
+
31
+ return res.json() as Promise<Record<string, any>>
32
+ }
33
+
34
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
35
+ return {
36
+ id: String(raw.sub),
37
+ name: raw.name ?? null,
38
+ email: raw.email ?? null,
39
+ avatar: raw.picture ?? null,
40
+ token: '',
41
+ refreshToken: null,
42
+ expiresIn: null,
43
+ raw,
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,47 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Microsoft OAuth 2.0 provider.
6
+ *
7
+ * Uses the Microsoft Identity Platform (v2.0) with the /common tenant,
8
+ * which supports both personal Microsoft accounts and Azure AD accounts.
9
+ */
10
+ export class MicrosoftProvider extends AbstractProvider {
11
+ override readonly name = 'microsoft'
12
+
13
+ protected override _scopes: string[] = ['openid', 'profile', 'email', 'User.Read']
14
+
15
+ protected override getAuthUrl(): string {
16
+ return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
17
+ }
18
+
19
+ protected override getTokenUrl(): string {
20
+ return 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
21
+ }
22
+
23
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
24
+ const res = await fetch('https://graph.microsoft.com/v1.0/me', {
25
+ headers: { Authorization: `Bearer ${token}` },
26
+ })
27
+
28
+ if (!res.ok) {
29
+ throw new Error(`Failed to fetch Microsoft user: ${res.status}`)
30
+ }
31
+
32
+ return res.json() as Promise<Record<string, any>>
33
+ }
34
+
35
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
36
+ return {
37
+ id: String(raw.id),
38
+ name: raw.displayName ?? null,
39
+ email: raw.mail ?? raw.userPrincipalName ?? null,
40
+ avatar: null, // Microsoft Graph photo requires a separate request
41
+ token: '',
42
+ refreshToken: null,
43
+ expiresIn: null,
44
+ raw,
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,138 @@
1
+ import { AbstractProvider } from '../AbstractProvider.ts'
2
+ import type { OAuthUser } from '../contracts/OAuthUser.ts'
3
+
4
+ /**
5
+ * Twitter (X) OAuth 2.0 provider with PKCE.
6
+ *
7
+ * Twitter's OAuth 2.0 implementation requires Proof Key for Code Exchange
8
+ * (PKCE). This provider generates a code_verifier and code_challenge for
9
+ * each authorization request.
10
+ */
11
+ export class TwitterProvider extends AbstractProvider {
12
+ override readonly name = 'twitter'
13
+
14
+ protected override _scopes: string[] = ['users.read', 'tweet.read']
15
+
16
+ /**
17
+ * Stored PKCE code_verifier — needed during the token exchange to prove
18
+ * we are the same client that initiated the authorization request.
19
+ */
20
+ private _codeVerifier: string | null = null
21
+
22
+ protected override getAuthUrl(): string {
23
+ return 'https://twitter.com/i/oauth2/authorize'
24
+ }
25
+
26
+ protected override getTokenUrl(): string {
27
+ return 'https://api.twitter.com/2/oauth2/token'
28
+ }
29
+
30
+ protected override async getUserByToken(token: string): Promise<Record<string, any>> {
31
+ const res = await fetch(
32
+ 'https://api.twitter.com/2/users/me?user.fields=profile_image_url',
33
+ {
34
+ headers: { Authorization: `Bearer ${token}` },
35
+ },
36
+ )
37
+
38
+ if (!res.ok) {
39
+ throw new Error(`Failed to fetch Twitter user: ${res.status}`)
40
+ }
41
+
42
+ return res.json() as Promise<Record<string, any>>
43
+ }
44
+
45
+ protected override mapUserToObject(raw: Record<string, any>): OAuthUser {
46
+ const data = raw.data ?? raw
47
+ return {
48
+ id: String(data.id),
49
+ name: data.name ?? null,
50
+ email: null, // Twitter does not provide email via this endpoint
51
+ avatar: data.profile_image_url ?? null,
52
+ token: '',
53
+ refreshToken: null,
54
+ expiresIn: null,
55
+ raw,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Override redirect to include PKCE parameters.
61
+ * Twitter requires code_challenge_method=S256.
62
+ */
63
+ override redirect(): Response {
64
+ this._codeVerifier = this.generateCodeVerifier()
65
+ const challenge = this.generateCodeChallenge(this._codeVerifier)
66
+
67
+ this.with({
68
+ code_challenge: challenge,
69
+ code_challenge_method: 'S256',
70
+ })
71
+
72
+ return super.redirect()
73
+ }
74
+
75
+ /**
76
+ * Override token exchange to include the code_verifier.
77
+ */
78
+ protected override async getAccessToken(
79
+ code: string,
80
+ ): Promise<{ access_token: string; refresh_token?: string; expires_in?: number }> {
81
+ const body: Record<string, string> = {
82
+ grant_type: 'authorization_code',
83
+ client_id: this.clientId,
84
+ code,
85
+ redirect_uri: this.redirectUrl,
86
+ }
87
+
88
+ if (this._codeVerifier) {
89
+ body.code_verifier = this._codeVerifier
90
+ }
91
+
92
+ const credentials = btoa(`${this.clientId}:${this.clientSecret}`)
93
+ const res = await fetch(this.getTokenUrl(), {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/x-www-form-urlencoded',
97
+ Accept: 'application/json',
98
+ Authorization: `Basic ${credentials}`,
99
+ },
100
+ body: new URLSearchParams(body),
101
+ })
102
+
103
+ if (!res.ok) {
104
+ throw new Error(`Token exchange failed: ${res.status}`)
105
+ }
106
+
107
+ return res.json() as Promise<{ access_token: string; refresh_token?: string; expires_in?: number }>
108
+ }
109
+
110
+ /**
111
+ * Generate a random code_verifier for PKCE.
112
+ * Must be between 43 and 128 characters (RFC 7636).
113
+ */
114
+ private generateCodeVerifier(): string {
115
+ const bytes = new Uint8Array(32)
116
+ crypto.getRandomValues(bytes)
117
+ return this.base64UrlEncode(bytes)
118
+ }
119
+
120
+ /**
121
+ * Generate a SHA-256 code_challenge from the code_verifier.
122
+ */
123
+ private generateCodeChallenge(verifier: string): string {
124
+ // Use synchronous approach: hash the verifier with SHA-256
125
+ const encoder = new TextEncoder()
126
+ const data = encoder.encode(verifier)
127
+ const hashBuffer = new Bun.CryptoHasher('sha256').update(data).digest()
128
+ return this.base64UrlEncode(new Uint8Array(hashBuffer))
129
+ }
130
+
131
+ /**
132
+ * Base64url encode bytes (no padding, URL-safe).
133
+ */
134
+ private base64UrlEncode(bytes: Uint8Array): string {
135
+ const base64 = btoa(String.fromCharCode(...bytes))
136
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
137
+ }
138
+ }