@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 +37 -0
- package/src/AbstractProvider.ts +131 -0
- package/src/SocialAuthManager.ts +110 -0
- package/src/SocialAuthServiceProvider.ts +38 -0
- package/src/contracts/OAuthProvider.ts +11 -0
- package/src/contracts/OAuthUser.ts +10 -0
- package/src/helpers/social-auth.ts +32 -0
- package/src/index.ts +27 -0
- package/src/providers/AppleProvider.ts +144 -0
- package/src/providers/DiscordProvider.ts +51 -0
- package/src/providers/FacebookProvider.ts +47 -0
- package/src/providers/GitHubProvider.ts +80 -0
- package/src/providers/GoogleProvider.ts +47 -0
- package/src/providers/LinkedInProvider.ts +46 -0
- package/src/providers/MicrosoftProvider.ts +47 -0
- package/src/providers/TwitterProvider.ts +138 -0
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,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
|
+
}
|