@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.
- package/README.md +226 -0
- package/index.d.ts +19 -0
- package/package.json +76 -0
- package/src/__tests__/auth.test.js +1171 -0
- package/src/__tests__/impersonation-test-setup.js +208 -0
- package/src/__tests__/impersonation.test.js +473 -0
- package/src/__tests__/oauth-test-setup.js +136 -0
- package/src/__tests__/oauth.test.js +400 -0
- package/src/__tests__/prsm.test.js +215 -0
- package/src/__tests__/test-setup.js +385 -0
- package/src/__tests__/totp.test.js +158 -0
- package/src/__tests__/two-factor-test-setup.js +331 -0
- package/src/__tests__/two-factor.test.js +396 -0
- package/src/activity-logger.js +228 -0
- package/src/auth-context.js +120 -0
- package/src/auth-functions.js +520 -0
- package/src/auth-manager.js +1371 -0
- package/src/errors.js +173 -0
- package/src/hooks.js +41 -0
- package/src/index.js +23 -0
- package/src/invalidation.js +166 -0
- package/src/middleware.js +33 -0
- package/src/providers/azure-provider.js +114 -0
- package/src/providers/base-provider.js +152 -0
- package/src/providers/github-provider.js +86 -0
- package/src/providers/google-provider.js +76 -0
- package/src/providers/index.js +4 -0
- package/src/queries.js +543 -0
- package/src/schema.js +261 -0
- package/src/totp.js +221 -0
- package/src/two-factor/index.js +3 -0
- package/src/two-factor/otp-provider.js +128 -0
- package/src/two-factor/totp-provider.js +98 -0
- package/src/two-factor/two-factor-manager.js +676 -0
- package/src/types.js +399 -0
- package/src/user-roles.js +128 -0
- package/src/util.js +32 -0
- package/types/activity-logger.d.ts +73 -0
- package/types/auth-context.d.ts +88 -0
- package/types/auth-functions.d.ts +151 -0
- package/types/auth-manager.d.ts +365 -0
- package/types/errors.d.ts +108 -0
- package/types/hooks.d.ts +30 -0
- package/types/index.d.ts +13 -0
- package/types/invalidation.d.ts +40 -0
- package/types/middleware.d.ts +11 -0
- package/types/providers/azure-provider.d.ts +35 -0
- package/types/providers/base-provider.d.ts +52 -0
- package/types/providers/github-provider.d.ts +29 -0
- package/types/providers/google-provider.d.ts +29 -0
- package/types/providers/index.d.ts +4 -0
- package/types/queries.d.ts +287 -0
- package/types/schema.d.ts +37 -0
- package/types/totp.d.ts +72 -0
- package/types/two-factor/index.d.ts +3 -0
- package/types/two-factor/otp-provider.d.ts +57 -0
- package/types/two-factor/totp-provider.d.ts +58 -0
- package/types/two-factor/two-factor-manager.d.ts +191 -0
- package/types/types.d.ts +688 -0
- package/types/user-roles.d.ts +47 -0
- 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
|
+
}
|