@prsm/auth 1.0.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.
Files changed (61) hide show
  1. package/README.md +226 -0
  2. package/index.d.ts +19 -0
  3. package/package.json +76 -0
  4. package/src/__tests__/auth.test.js +1171 -0
  5. package/src/__tests__/impersonation-test-setup.js +208 -0
  6. package/src/__tests__/impersonation.test.js +473 -0
  7. package/src/__tests__/oauth-test-setup.js +136 -0
  8. package/src/__tests__/oauth.test.js +400 -0
  9. package/src/__tests__/prsm.test.js +215 -0
  10. package/src/__tests__/test-setup.js +385 -0
  11. package/src/__tests__/totp.test.js +158 -0
  12. package/src/__tests__/two-factor-test-setup.js +331 -0
  13. package/src/__tests__/two-factor.test.js +396 -0
  14. package/src/activity-logger.js +228 -0
  15. package/src/auth-context.js +120 -0
  16. package/src/auth-functions.js +520 -0
  17. package/src/auth-manager.js +1371 -0
  18. package/src/errors.js +173 -0
  19. package/src/hooks.js +41 -0
  20. package/src/index.js +23 -0
  21. package/src/invalidation.js +166 -0
  22. package/src/middleware.js +33 -0
  23. package/src/providers/azure-provider.js +114 -0
  24. package/src/providers/base-provider.js +152 -0
  25. package/src/providers/github-provider.js +86 -0
  26. package/src/providers/google-provider.js +76 -0
  27. package/src/providers/index.js +4 -0
  28. package/src/queries.js +543 -0
  29. package/src/schema.js +261 -0
  30. package/src/totp.js +221 -0
  31. package/src/two-factor/index.js +3 -0
  32. package/src/two-factor/otp-provider.js +128 -0
  33. package/src/two-factor/totp-provider.js +98 -0
  34. package/src/two-factor/two-factor-manager.js +676 -0
  35. package/src/types.js +399 -0
  36. package/src/user-roles.js +128 -0
  37. package/src/util.js +32 -0
  38. package/types/activity-logger.d.ts +73 -0
  39. package/types/auth-context.d.ts +88 -0
  40. package/types/auth-functions.d.ts +151 -0
  41. package/types/auth-manager.d.ts +365 -0
  42. package/types/errors.d.ts +108 -0
  43. package/types/hooks.d.ts +30 -0
  44. package/types/index.d.ts +13 -0
  45. package/types/invalidation.d.ts +40 -0
  46. package/types/middleware.d.ts +11 -0
  47. package/types/providers/azure-provider.d.ts +35 -0
  48. package/types/providers/base-provider.d.ts +52 -0
  49. package/types/providers/github-provider.d.ts +29 -0
  50. package/types/providers/google-provider.d.ts +29 -0
  51. package/types/providers/index.d.ts +4 -0
  52. package/types/queries.d.ts +287 -0
  53. package/types/schema.d.ts +37 -0
  54. package/types/totp.d.ts +72 -0
  55. package/types/two-factor/index.d.ts +3 -0
  56. package/types/two-factor/otp-provider.d.ts +57 -0
  57. package/types/two-factor/totp-provider.d.ts +58 -0
  58. package/types/two-factor/two-factor-manager.d.ts +191 -0
  59. package/types/types.d.ts +688 -0
  60. package/types/user-roles.d.ts +47 -0
  61. package/types/util.d.ts +3 -0
package/src/errors.js ADDED
@@ -0,0 +1,173 @@
1
+ export class AuthError extends Error {
2
+ /** @param {string} message */
3
+ constructor(message) {
4
+ super(message)
5
+ this.name = this.constructor.name
6
+ }
7
+ }
8
+
9
+ export class ConfirmationExpiredError extends AuthError {
10
+ constructor() {
11
+ super("Confirmation token has expired")
12
+ }
13
+ }
14
+
15
+ export class ConfirmationNotFoundError extends AuthError {
16
+ constructor() {
17
+ super("Confirmation token not found")
18
+ }
19
+ }
20
+
21
+ export class EmailNotVerifiedError extends AuthError {
22
+ constructor() {
23
+ super("Email address has not been verified")
24
+ }
25
+ }
26
+
27
+ export class EmailTakenError extends AuthError {
28
+ constructor() {
29
+ super("Email address is already in use")
30
+ }
31
+ }
32
+
33
+ export class InvalidEmailError extends AuthError {
34
+ constructor() {
35
+ super("Invalid email address format")
36
+ }
37
+ }
38
+
39
+ export class InvalidPasswordError extends AuthError {
40
+ constructor() {
41
+ super("Invalid password")
42
+ }
43
+ }
44
+
45
+ export class InvalidTokenError extends AuthError {
46
+ constructor() {
47
+ super("Invalid token")
48
+ }
49
+ }
50
+
51
+ export class ResetDisabledError extends AuthError {
52
+ constructor() {
53
+ super("Password reset is disabled for this account")
54
+ }
55
+ }
56
+
57
+ export class ResetExpiredError extends AuthError {
58
+ constructor() {
59
+ super("Password reset token has expired")
60
+ }
61
+ }
62
+
63
+ export class ResetNotFoundError extends AuthError {
64
+ constructor() {
65
+ super("Password reset token not found")
66
+ }
67
+ }
68
+
69
+ export class TooManyResetsError extends AuthError {
70
+ constructor() {
71
+ super("Too many password reset requests")
72
+ }
73
+ }
74
+
75
+ export class UserInactiveError extends AuthError {
76
+ constructor() {
77
+ super("User account is inactive")
78
+ }
79
+ }
80
+
81
+ export class UserNotFoundError extends AuthError {
82
+ constructor() {
83
+ super("User not found")
84
+ }
85
+ }
86
+
87
+ export class UserNotLoggedInError extends AuthError {
88
+ constructor() {
89
+ super("User is not logged in")
90
+ }
91
+ }
92
+
93
+ export class RateLimitedError extends AuthError {
94
+ /** @param {number} [retryAfter] milliseconds until the next attempt is allowed */
95
+ constructor(retryAfter) {
96
+ super("Too many attempts")
97
+ this.retryAfter = retryAfter
98
+ }
99
+ }
100
+
101
+ // two-factor authentication errors
102
+
103
+ export class SecondFactorRequiredError extends AuthError {
104
+ /**
105
+ * @param {{ totp?: boolean, email?: { otpValue: string, maskedContact: string }, sms?: { otpValue: string, maskedContact: string } }} availableMethods
106
+ */
107
+ constructor(availableMethods) {
108
+ super("Second factor authentication required")
109
+ this.availableMethods = availableMethods
110
+ }
111
+ }
112
+
113
+ export class InvalidTwoFactorCodeError extends AuthError {
114
+ constructor() {
115
+ super("Invalid two-factor authentication code")
116
+ }
117
+ }
118
+
119
+ export class TwoFactorExpiredError extends AuthError {
120
+ constructor() {
121
+ super("Two-factor authentication session has expired")
122
+ }
123
+ }
124
+
125
+ export class TwoFactorNotSetupError extends AuthError {
126
+ constructor() {
127
+ super("Two-factor authentication is not set up for this account")
128
+ }
129
+ }
130
+
131
+ export class TwoFactorAlreadyEnabledError extends AuthError {
132
+ constructor() {
133
+ super("Two-factor authentication is already enabled for this mechanism")
134
+ }
135
+ }
136
+
137
+ export class InvalidBackupCodeError extends AuthError {
138
+ constructor() {
139
+ super("Invalid backup code")
140
+ }
141
+ }
142
+
143
+ export class TwoFactorSetupIncompleteError extends AuthError {
144
+ constructor() {
145
+ super("Two-factor authentication setup is not complete")
146
+ }
147
+ }
148
+
149
+ // impersonation errors
150
+
151
+ export class ImpersonationDisabledError extends AuthError {
152
+ constructor() {
153
+ super("Impersonation is not enabled in this configuration")
154
+ }
155
+ }
156
+
157
+ export class ImpersonationNotAllowedError extends AuthError {
158
+ constructor() {
159
+ super("Impersonation is not permitted for this actor and target")
160
+ }
161
+ }
162
+
163
+ export class AlreadyImpersonatingError extends AuthError {
164
+ constructor() {
165
+ super("An impersonation session is already active")
166
+ }
167
+ }
168
+
169
+ export class NotImpersonatingError extends AuthError {
170
+ constructor() {
171
+ super("No active impersonation session")
172
+ }
173
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,41 @@
1
+ // thin, duck-typed adapters for the optional @prsm/trace and @prsm/limit
2
+ // instances. auth never imports those packages; it only calls what's passed in,
3
+ // so either can be absent without any coupling
4
+
5
+ /**
6
+ * @typedef {import("./types.js").Tracer} Tracer
7
+ * @typedef {import("./types.js").Limiter} Limiter
8
+ */
9
+
10
+ /**
11
+ * Run fn inside a tracing span when a tracer is provided, otherwise run it plain.
12
+ * Matches the @prsm/trace tracer shape: tracer.span(name, attributes, fn).
13
+ * @template T
14
+ * @param {Tracer | undefined} tracer
15
+ * @param {string} name
16
+ * @param {Record<string, any>} attributes
17
+ * @param {() => Promise<T>} fn
18
+ * @returns {Promise<T>}
19
+ */
20
+ export function withSpan(tracer, name, attributes, fn) {
21
+ if (tracer && typeof tracer.span === "function") {
22
+ return tracer.span(name, attributes, fn)
23
+ }
24
+ return fn()
25
+ }
26
+
27
+ /**
28
+ * Consume one unit against a limiter for the given key. The @prsm/limit
29
+ * algorithms expose different verbs (tokenBucket.take, slidingWindow.hit,
30
+ * leakyBucket.drip); all return { allowed, retryAfter }. We accept any of them
31
+ * plus a generic consume/check so callers can pass a limiter instance directly.
32
+ * @param {Limiter | undefined} limiter
33
+ * @param {string} key
34
+ * @returns {Promise<{ allowed: boolean, retryAfter?: number } | null>}
35
+ */
36
+ export async function consumeLimit(limiter, key) {
37
+ if (!limiter) return null
38
+ const fn = limiter.take || limiter.hit || limiter.drip || limiter.consume || limiter.check
39
+ if (typeof fn !== "function") return null
40
+ return fn.call(limiter, key)
41
+ }
package/src/index.js ADDED
@@ -0,0 +1,23 @@
1
+ export { createAuthMiddleware } from "./middleware.js"
2
+ export { createAuthTables, dropAuthTables, cleanupExpiredTokens, getAuthTableStats } from "./schema.js"
3
+ export { createAuthContext } from "./auth-context.js"
4
+ export * as authFunctions from "./auth-functions.js"
5
+ export * from "./auth-functions.js"
6
+ export { defineRoles, addRoleToUser, removeRoleFromUser, setUserRoles, getUserRoles } from "./user-roles.js"
7
+
8
+ // types and runtime enums (AuthStatus, AuthRole, AuthActivityAction, TwoFactorMechanism)
9
+ export * from "./types.js"
10
+
11
+ // error classes
12
+ export * from "./errors.js"
13
+
14
+ export { isValidEmail, validateEmail } from "./util.js"
15
+
16
+ export { ActivityLogger } from "./activity-logger.js"
17
+
18
+ export { TwoFactorManager, TotpProvider, OtpProvider } from "./two-factor/index.js"
19
+
20
+ export { GitHubProvider, GoogleProvider, AzureProvider, BaseOAuthProvider } from "./providers/index.js"
21
+
22
+ // cross-instance invalidation teardown (graceful shutdown / tests)
23
+ export { closeInvalidationListeners } from "./invalidation.js"
@@ -0,0 +1,166 @@
1
+ // optional cross-instance session invalidation over postgres LISTEN/NOTIFY.
2
+ // when config.invalidation.listen is true, security-relevant account writes
3
+ // (force-logout, status, role, password) emit pg_notify, and every process
4
+ // running a listener drops the matching session on its next request instead of
5
+ // waiting up to resyncInterval. this needs no extra dependency - postgres is
6
+ // already required. it degrades silently to poll-based resync when the listener
7
+ // connection or NOTIFY is unavailable (e.g. pgbouncer in transaction mode)
8
+
9
+ /**
10
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
11
+ */
12
+
13
+ const DEFAULT_CHANNEL = "prsm_auth_invalidate"
14
+
15
+ // prune invalidation marks older than this; a mark only matters until the
16
+ // affected session resyncs, which happens well within a minute
17
+ const MARK_TTL_MS = 5 * 60 * 1000
18
+
19
+ /**
20
+ * @typedef {object} ListenerState
21
+ * @property {string} channel
22
+ * @property {Map<number, number>} invalidated accountId -> timestamp of last signal
23
+ * @property {boolean} started
24
+ * @property {boolean} broken
25
+ * @property {import("pg").PoolClient | null} client
26
+ */
27
+
28
+ // per-pool, per-channel listener state. pools are long-lived objects, so a Map
29
+ // keyed by the pool is the natural scope
30
+ /** @type {Map<import("pg").Pool, Map<string, ListenerState>>} */
31
+ const registry = new Map()
32
+
33
+ /**
34
+ * @param {AuthConfig} config
35
+ * @returns {string}
36
+ */
37
+ function channelFor(config) {
38
+ return config.invalidation?.channel || DEFAULT_CHANNEL
39
+ }
40
+
41
+ /**
42
+ * @param {AuthConfig} config
43
+ * @returns {ListenerState | null}
44
+ */
45
+ function getState(config) {
46
+ const pool = config.db
47
+ const channel = channelFor(config)
48
+ let byChannel = registry.get(pool)
49
+ if (!byChannel) {
50
+ byChannel = new Map()
51
+ registry.set(pool, byChannel)
52
+ }
53
+ let state = byChannel.get(channel)
54
+ if (!state) {
55
+ state = { channel, invalidated: new Map(), started: false, broken: false, client: null }
56
+ byChannel.set(channel, state)
57
+ }
58
+ return state
59
+ }
60
+
61
+ /**
62
+ * Start the LISTEN connection for this config's pool/channel if it isn't already
63
+ * running. Idempotent and non-blocking - the first call kicks off the connection
64
+ * and returns; failures mark the listener broken so callers fall back to polling.
65
+ * @param {AuthConfig} config
66
+ */
67
+ export function ensureListener(config) {
68
+ if (!config.invalidation?.listen) return
69
+
70
+ const state = getState(config)
71
+ if (!state || state.started || state.broken) return
72
+ state.started = true
73
+
74
+ const pool = config.db
75
+ const channel = state.channel
76
+
77
+ pool
78
+ .connect()
79
+ .then(async (client) => {
80
+ state.client = client
81
+ client.on("notification", (msg) => {
82
+ if (msg.channel !== channel || !msg.payload) return
83
+ const accountId = parseInt(msg.payload, 10)
84
+ if (!Number.isNaN(accountId)) {
85
+ state.invalidated.set(accountId, Date.now())
86
+ pruneMarks(state)
87
+ }
88
+ })
89
+ client.on("error", () => {
90
+ state.broken = true
91
+ })
92
+ await client.query(`LISTEN ${channel}`)
93
+ })
94
+ .catch(() => {
95
+ // listener unavailable (e.g. pooler without session support) - fall back to poll
96
+ state.broken = true
97
+ state.started = false
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Broadcast that an account's auth state changed so other instances resync it
103
+ * immediately. No-op unless invalidation is enabled.
104
+ * @param {AuthConfig} config
105
+ * @param {number} accountId
106
+ * @returns {Promise<void>}
107
+ */
108
+ export async function notifyInvalidation(config, accountId) {
109
+ if (!config.invalidation?.listen) return
110
+ try {
111
+ await config.db.query("SELECT pg_notify($1, $2)", [channelFor(config), String(accountId)])
112
+ } catch {
113
+ // notify is best-effort; poll-based resync remains the backstop
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Whether the given account was signaled invalid more recently than `sinceTs`.
119
+ * @param {AuthConfig} config
120
+ * @param {number} accountId
121
+ * @param {number} sinceTs epoch millis
122
+ * @returns {boolean}
123
+ */
124
+ export function wasInvalidatedSince(config, accountId, sinceTs) {
125
+ if (!config.invalidation?.listen) return false
126
+ const pool = config.db
127
+ const byChannel = registry.get(pool)
128
+ if (!byChannel) return false
129
+ const state = byChannel.get(channelFor(config))
130
+ if (!state) return false
131
+ const ts = state.invalidated.get(accountId)
132
+ return ts != null && ts > sinceTs
133
+ }
134
+
135
+ /**
136
+ * @param {ListenerState} state
137
+ */
138
+ function pruneMarks(state) {
139
+ const cutoff = Date.now() - MARK_TTL_MS
140
+ for (const [accountId, ts] of state.invalidated) {
141
+ if (ts < cutoff) state.invalidated.delete(accountId)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Release all listener connections. Intended for test teardown and graceful
147
+ * shutdown - production processes normally keep listeners for their lifetime.
148
+ * @returns {Promise<void>}
149
+ */
150
+ export async function closeInvalidationListeners() {
151
+ for (const byChannel of registry.values()) {
152
+ for (const state of byChannel.values()) {
153
+ if (state.client) {
154
+ try {
155
+ state.client.release()
156
+ } catch {
157
+ // ignore
158
+ }
159
+ }
160
+ state.client = null
161
+ state.started = false
162
+ state.invalidated.clear()
163
+ }
164
+ }
165
+ registry.clear()
166
+ }
@@ -0,0 +1,33 @@
1
+ import { AuthManager } from "./auth-manager.js"
2
+ import { ensureListener } from "./invalidation.js"
3
+
4
+ /**
5
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
6
+ */
7
+
8
+ /**
9
+ * Create the Express middleware that attaches an AuthManager to req.auth,
10
+ * resyncs the session, and processes any remember-me token.
11
+ * @param {AuthConfig} config
12
+ * @returns {import("express").RequestHandler}
13
+ */
14
+ export function createAuthMiddleware(config) {
15
+ // start the cross-instance invalidation listener once if enabled; idempotent
16
+ // and non-blocking, falls back to poll-based resync if unavailable
17
+ ensureListener(config)
18
+
19
+ return async (req, res, next) => {
20
+ try {
21
+ const authManager = new AuthManager(req, res, config)
22
+
23
+ req.auth = authManager
24
+
25
+ await authManager.resyncSession()
26
+ await authManager.processRememberDirective()
27
+
28
+ next()
29
+ } catch (error) {
30
+ next(error)
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,114 @@
1
+ import { BaseOAuthProvider } from "./base-provider.js"
2
+
3
+ /**
4
+ * @typedef {import("../types.js").AuthConfig} AuthConfig
5
+ * @typedef {import("../types.js").OAuthUserData} OAuthUserData
6
+ * @typedef {import("../types.js").AzureProviderConfig} AzureProviderConfig
7
+ */
8
+
9
+ export class AzureProvider extends BaseOAuthProvider {
10
+ /**
11
+ * @param {AzureProviderConfig} config
12
+ * @param {AuthConfig} authConfig
13
+ * @param {import("../auth-manager.js").AuthManager} authManager
14
+ */
15
+ constructor(config, authConfig, authManager) {
16
+ super(config, authConfig, authManager)
17
+ }
18
+
19
+ /**
20
+ * Build the Azure AD authorization URL.
21
+ * @param {string} [state]
22
+ * @param {string[]} [scopes]
23
+ * @returns {string}
24
+ */
25
+ getAuthUrl(state, scopes) {
26
+ const azureConfig = this.config
27
+ const params = new URLSearchParams({
28
+ client_id: azureConfig.clientId,
29
+ redirect_uri: azureConfig.redirectUri,
30
+ scope: scopes?.join(" ") || "openid profile email User.Read",
31
+ state: state || crypto.randomUUID(),
32
+ response_type: "code",
33
+ response_mode: "query",
34
+ })
35
+
36
+ return `https://login.microsoftonline.com/${azureConfig.tenantId}/oauth2/v2.0/authorize?${params}`
37
+ }
38
+
39
+ /**
40
+ * Exchange the callback code and resolve the Azure user profile.
41
+ * @param {import("express").Request} req
42
+ * @returns {Promise<OAuthUserData>}
43
+ * @throws {Error} when no code is provided or no email is found
44
+ */
45
+ async getUserData(req) {
46
+ const code = req.query.code
47
+ if (!code) {
48
+ throw new Error("No authorization code provided")
49
+ }
50
+
51
+ // exchange code for access token
52
+ const azureConfig = this.config
53
+ const accessToken = await this.exchangeCodeForToken(code, `https://login.microsoftonline.com/${azureConfig.tenantId}/oauth2/v2.0/token`)
54
+
55
+ // fetch user data from Microsoft Graph
56
+ const user = await this.fetchUserFromAPI(accessToken, "https://graph.microsoft.com/v1.0/me")
57
+
58
+ if (!user.mail && !user.userPrincipalName) {
59
+ throw new Error("No email found in Azure account")
60
+ }
61
+
62
+ return {
63
+ id: user.id,
64
+ email: user.mail || user.userPrincipalName,
65
+ username: user.mailNickname || user.userPrincipalName?.split("@")[0],
66
+ name: user.displayName,
67
+ avatar: undefined, // Azure doesn't provide avatar in basic profile
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @returns {string}
73
+ */
74
+ getProviderName() {
75
+ return "azure"
76
+ }
77
+
78
+ /**
79
+ * Exchange an authorization code for an Azure access token.
80
+ * @param {string} code
81
+ * @param {string} tokenUrl
82
+ * @returns {Promise<string>}
83
+ * @throws {Error} when the exchange fails or no access token is returned
84
+ */
85
+ async exchangeCodeForToken(code, tokenUrl) {
86
+ const azureConfig = this.config
87
+ const response = await fetch(tokenUrl, {
88
+ method: "POST",
89
+ headers: {
90
+ Accept: "application/json",
91
+ "Content-Type": "application/x-www-form-urlencoded",
92
+ },
93
+ body: new URLSearchParams({
94
+ client_id: azureConfig.clientId,
95
+ client_secret: azureConfig.clientSecret,
96
+ code,
97
+ redirect_uri: azureConfig.redirectUri,
98
+ grant_type: "authorization_code",
99
+ scope: "openid profile email User.Read",
100
+ }),
101
+ })
102
+
103
+ if (!response.ok) {
104
+ throw new Error(`OAuth token exchange failed: ${response.status} ${response.statusText}`)
105
+ }
106
+
107
+ const data = await response.json()
108
+ if (!data.access_token) {
109
+ throw new Error("No access token received from Azure")
110
+ }
111
+
112
+ return data.access_token
113
+ }
114
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @typedef {import("../types.js").AuthConfig} AuthConfig
3
+ * @typedef {import("../types.js").OAuthUserData} OAuthUserData
4
+ * @typedef {import("../types.js").OAuthCallbackResult} OAuthCallbackResult
5
+ * @typedef {import("../types.js").OAuthProviderConfig} OAuthProviderConfig
6
+ */
7
+
8
+ export class BaseOAuthProvider {
9
+ /**
10
+ * @param {OAuthProviderConfig} config
11
+ * @param {AuthConfig} authConfig
12
+ * @param {import("../auth-manager.js").AuthManager} authManager
13
+ */
14
+ constructor(config, authConfig, authManager) {
15
+ this.config = config
16
+ this.authConfig = authConfig
17
+ this.authManager = authManager
18
+ }
19
+
20
+ /**
21
+ * Handle an OAuth provider callback request and resolve the login.
22
+ * @param {import("express").Request} req
23
+ * @returns {Promise<OAuthCallbackResult>}
24
+ */
25
+ async handleCallback(req) {
26
+ const userData = await this.getUserData(req)
27
+ return this.processOAuthLogin(userData, req)
28
+ }
29
+
30
+ /**
31
+ * Resolve or create an account for the OAuth user and record the login.
32
+ * @param {OAuthUserData} userData
33
+ * @param {import("express").Request} req
34
+ * @returns {Promise<OAuthCallbackResult>}
35
+ * @throws {Error} when an account already exists for the user's email
36
+ */
37
+ async processOAuthLogin(userData, req) {
38
+ const { queries } = this.authManager
39
+ const providerName = this.getProviderName()
40
+
41
+ const existingProvider = await queries.findProviderByProviderIdAndType(userData.id, providerName)
42
+
43
+ if (existingProvider) {
44
+ const account = await queries.findAccountById(existingProvider.account_id)
45
+ if (account) {
46
+ await this.authManager.onLoginSuccessful(account, true)
47
+ return { isNewUser: false }
48
+ }
49
+ }
50
+
51
+ // new OAuth user - check if email already exists
52
+ if (userData.email) {
53
+ const existingAccount = await queries.findAccountByEmail(userData.email)
54
+ if (existingAccount) {
55
+ throw new Error("You already have an account associated with this email address.")
56
+ }
57
+ }
58
+
59
+ // create new user and account
60
+ let userId
61
+
62
+ if (this.authConfig.createUser) {
63
+ userId = await this.authConfig.createUser(userData)
64
+ } else {
65
+ // Generate UUID for OAuth users when no createUser function is provided
66
+ userId = crypto.randomUUID()
67
+ }
68
+
69
+ // create the auth account (no password for OAuth)
70
+ const account = await queries.createAccount({
71
+ userId,
72
+ email: userData.email,
73
+ password: null,
74
+ verified: true, // OAuth providers are pre-verified
75
+ status: 0, // AuthStatus.Normal
76
+ rolemask: 0,
77
+ })
78
+
79
+ // create the provider record
80
+ await queries.createProvider({
81
+ accountId: account.id,
82
+ provider: providerName,
83
+ providerId: userData.id,
84
+ providerEmail: userData.email,
85
+ providerUsername: userData.username || null,
86
+ providerName: userData.name || null,
87
+ providerAvatar: userData.avatar || null,
88
+ })
89
+
90
+ await this.authManager.onLoginSuccessful(account, true)
91
+ return { isNewUser: true }
92
+ }
93
+
94
+ /**
95
+ * Exchange an authorization code for an access token.
96
+ * @param {string} code
97
+ * @param {string} tokenUrl
98
+ * @returns {Promise<string>}
99
+ * @throws {Error} when the exchange fails or no access token is returned
100
+ */
101
+ async exchangeCodeForToken(code, tokenUrl) {
102
+ const response = await fetch(tokenUrl, {
103
+ method: "POST",
104
+ headers: {
105
+ Accept: "application/json",
106
+ "Content-Type": "application/x-www-form-urlencoded",
107
+ },
108
+ body: new URLSearchParams({
109
+ client_id: this.config.clientId,
110
+ client_secret: this.config.clientSecret,
111
+ code,
112
+ redirect_uri: this.config.redirectUri,
113
+ grant_type: "authorization_code",
114
+ }),
115
+ })
116
+
117
+ if (!response.ok) {
118
+ throw new Error(`OAuth token exchange failed: ${response.status} ${response.statusText}`)
119
+ }
120
+
121
+ const data = await response.json()
122
+ if (!data.access_token) {
123
+ throw new Error("No access token received from OAuth provider")
124
+ }
125
+
126
+ return data.access_token
127
+ }
128
+
129
+ /**
130
+ * Fetch the authenticated user profile from a provider API.
131
+ * @param {string} accessToken
132
+ * @param {string} apiUrl
133
+ * @param {Record<string, string>} [headers]
134
+ * @returns {Promise<any>}
135
+ * @throws {Error} when the request fails
136
+ */
137
+ async fetchUserFromAPI(accessToken, apiUrl, headers = {}) {
138
+ const response = await fetch(apiUrl, {
139
+ headers: {
140
+ Authorization: `Bearer ${accessToken}`,
141
+ Accept: "application/json",
142
+ ...headers,
143
+ },
144
+ })
145
+
146
+ if (!response.ok) {
147
+ throw new Error(`Failed to fetch user data: ${response.status} ${response.statusText}`)
148
+ }
149
+
150
+ return response.json()
151
+ }
152
+ }