@stravigor/bastion 0.2.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.
@@ -0,0 +1,172 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import type Session from '@stravigor/core/session/session'
3
+ import Emitter from '@stravigor/core/events/emitter'
4
+ import BastionManager from '../bastion_manager.ts'
5
+ import { BastionEvents } from '../types.ts'
6
+ import {
7
+ generateSecret,
8
+ totpUri,
9
+ verifyTotp,
10
+ base32Decode,
11
+ generateRecoveryCodes,
12
+ } from '../totp.ts'
13
+ import { completeLogin } from './login.ts'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // POST /two-factor/enable — generate a TOTP secret
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export async function enableTwoFactorHandler(ctx: Context): Promise<Response> {
20
+ const user = ctx.get('user')
21
+ const actions = BastionManager.actions
22
+ const config = BastionManager.config.twoFactor
23
+
24
+ // Don't allow re-enabling if already confirmed
25
+ const existingSecret = actions.twoFactorSecretOf!(user)
26
+ if (existingSecret) {
27
+ return ctx.json({ message: 'Two-factor authentication is already enabled.' }, 409)
28
+ }
29
+
30
+ const { base32 } = generateSecret()
31
+ const email = actions.emailOf(user)
32
+
33
+ const uri = totpUri({
34
+ secret: base32,
35
+ issuer: config.issuer,
36
+ account: email,
37
+ digits: config.digits,
38
+ period: config.period,
39
+ })
40
+
41
+ // Store the unconfirmed secret in the session (not persisted to user yet)
42
+ const session = ctx.get<Session>('session')
43
+ session.set('_bastion_2fa_secret', base32)
44
+
45
+ return ctx.json({ secret: base32, qr_uri: uri })
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // POST /two-factor/confirm — confirm 2FA setup with a valid code
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export async function confirmTwoFactorHandler(ctx: Context): Promise<Response> {
53
+ const body = await ctx.body<{ code?: string }>()
54
+ if (!body.code) {
55
+ return ctx.json({ message: 'Verification code is required.' }, 422)
56
+ }
57
+
58
+ const user = ctx.get('user')
59
+ const actions = BastionManager.actions
60
+ const config = BastionManager.config.twoFactor
61
+
62
+ const session = ctx.get<Session>('session')
63
+ const pendingSecret = session.get<string>('_bastion_2fa_secret')
64
+ if (!pendingSecret) {
65
+ return ctx.json({ message: 'No pending two-factor setup. Call enable first.' }, 422)
66
+ }
67
+
68
+ // Verify the code against the pending secret
69
+ const secretBytes = base32Decode(pendingSecret)
70
+ const valid = await verifyTotp(secretBytes, body.code, {
71
+ digits: config.digits,
72
+ period: config.period,
73
+ })
74
+
75
+ if (!valid) {
76
+ return ctx.json({ message: 'Invalid verification code.' }, 422)
77
+ }
78
+
79
+ // Persist the secret to the user
80
+ await actions.setTwoFactorSecret!(user, pendingSecret)
81
+
82
+ // Generate recovery codes
83
+ const codes = generateRecoveryCodes(config.recoveryCodes)
84
+ await actions.setRecoveryCodes!(user, codes)
85
+
86
+ // Clean up session
87
+ session.forget('_bastion_2fa_secret')
88
+
89
+ if (Emitter.listenerCount(BastionEvents.TWO_FACTOR_ENABLED) > 0) {
90
+ Emitter.emit(BastionEvents.TWO_FACTOR_ENABLED, { user, ctx }).catch(() => {})
91
+ }
92
+
93
+ return ctx.json({ message: 'Two-factor authentication enabled.', recovery_codes: codes })
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // DELETE /two-factor — disable 2FA
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export async function disableTwoFactorHandler(ctx: Context): Promise<Response> {
101
+ const user = ctx.get('user')
102
+ const actions = BastionManager.actions
103
+
104
+ await actions.setTwoFactorSecret!(user, null)
105
+ await actions.setRecoveryCodes!(user, [])
106
+
107
+ if (Emitter.listenerCount(BastionEvents.TWO_FACTOR_DISABLED) > 0) {
108
+ Emitter.emit(BastionEvents.TWO_FACTOR_DISABLED, { user, ctx }).catch(() => {})
109
+ }
110
+
111
+ return ctx.json({ message: 'Two-factor authentication disabled.' })
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // POST /two-factor/challenge — verify TOTP code during login
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export async function twoFactorChallengeHandler(ctx: Context): Promise<Response> {
119
+ const body = await ctx.body<{ code?: string; recovery_code?: string }>()
120
+ const session = ctx.get<Session>('session')
121
+ const actions = BastionManager.actions
122
+ const config = BastionManager.config.twoFactor
123
+
124
+ // Retrieve the pending login email from the session
125
+ const pendingEmail = session.get<string>('_bastion_2fa_email')
126
+ if (!pendingEmail) {
127
+ return ctx.json({ message: 'No pending two-factor challenge.' }, 422)
128
+ }
129
+
130
+ const user = await actions.findByEmail(pendingEmail)
131
+ if (!user) {
132
+ return ctx.json({ message: 'Invalid challenge.' }, 422)
133
+ }
134
+
135
+ const secret = actions.twoFactorSecretOf!(user)
136
+ if (!secret) {
137
+ return ctx.json({ message: 'Two-factor authentication is not enabled.' }, 422)
138
+ }
139
+
140
+ // Try TOTP code first
141
+ if (body.code) {
142
+ const secretBytes = base32Decode(secret)
143
+ const valid = await verifyTotp(secretBytes, body.code, {
144
+ digits: config.digits,
145
+ period: config.period,
146
+ })
147
+
148
+ if (!valid) {
149
+ return ctx.json({ message: 'Invalid two-factor code.' }, 422)
150
+ }
151
+ }
152
+ // Try recovery code
153
+ else if (body.recovery_code) {
154
+ const codes = actions.recoveryCodesOf!(user)
155
+ const index = codes.indexOf(body.recovery_code)
156
+ if (index === -1) {
157
+ return ctx.json({ message: 'Invalid recovery code.' }, 422)
158
+ }
159
+
160
+ // Remove the used recovery code
161
+ codes.splice(index, 1)
162
+ await actions.setRecoveryCodes!(user, codes)
163
+ } else {
164
+ return ctx.json({ message: 'A two-factor code or recovery code is required.' }, 422)
165
+ }
166
+
167
+ // Clean up pending state
168
+ session.forget('_bastion_2fa_email')
169
+
170
+ // Complete the login
171
+ return completeLogin(ctx, user)
172
+ }
@@ -0,0 +1,40 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import { encrypt } from '@stravigor/core/encryption'
3
+ import Emitter from '@stravigor/core/events/emitter'
4
+ import BastionManager from '../bastion_manager.ts'
5
+ import { BastionEvents } from '../types.ts'
6
+
7
+ export async function updatePasswordHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{
9
+ current_password?: string
10
+ password?: string
11
+ password_confirmation?: string
12
+ }>()
13
+
14
+ // Validate
15
+ if (!body.current_password) {
16
+ return ctx.json({ message: 'Current password is required.' }, 422)
17
+ }
18
+ if (!body.password || body.password.length < 8) {
19
+ return ctx.json({ message: 'New password must be at least 8 characters.' }, 422)
20
+ }
21
+ if (body.password !== body.password_confirmation) {
22
+ return ctx.json({ message: 'Passwords do not match.' }, 422)
23
+ }
24
+
25
+ const user = ctx.get('user')
26
+ const hash = BastionManager.actions.passwordHashOf(user)
27
+ const valid = await encrypt.verify(body.current_password, hash)
28
+
29
+ if (!valid) {
30
+ return ctx.json({ message: 'Current password is incorrect.' }, 422)
31
+ }
32
+
33
+ await BastionManager.actions.updatePassword(user, body.password)
34
+
35
+ if (Emitter.listenerCount(BastionEvents.PASSWORD_UPDATED) > 0) {
36
+ Emitter.emit(BastionEvents.PASSWORD_UPDATED, { user, ctx }).catch(() => {})
37
+ }
38
+
39
+ return ctx.json({ message: 'Password updated.' })
40
+ }
@@ -0,0 +1,17 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import Emitter from '@stravigor/core/events/emitter'
3
+ import BastionManager from '../bastion_manager.ts'
4
+ import { BastionEvents } from '../types.ts'
5
+
6
+ export async function updateProfileHandler(ctx: Context): Promise<Response> {
7
+ const data = await ctx.body<Record<string, unknown>>()
8
+ const user = ctx.get('user')
9
+
10
+ await BastionManager.actions.updateProfile!(user, data)
11
+
12
+ if (Emitter.listenerCount(BastionEvents.PROFILE_UPDATED) > 0) {
13
+ Emitter.emit(BastionEvents.PROFILE_UPDATED, { user, ctx }).catch(() => {})
14
+ }
15
+
16
+ return ctx.json({ message: 'Profile updated.' })
17
+ }
@@ -0,0 +1,83 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import { mail } from '@stravigor/core/mail'
3
+ import { extractUserId } from '@stravigor/core/helpers/identity'
4
+ import Emitter from '@stravigor/core/events/emitter'
5
+ import BastionManager from '../bastion_manager.ts'
6
+ import { createSignedToken, verifySignedToken } from '../tokens.ts'
7
+ import { BastionEvents } from '../types.ts'
8
+
9
+ /** Send a verification email for the given user. */
10
+ export async function sendVerificationEmail(user: unknown): Promise<void> {
11
+ const config = BastionManager.config
12
+ const actions = BastionManager.actions
13
+ const userId = extractUserId(user)
14
+ const email = actions.emailOf(user)
15
+
16
+ const token = createSignedToken(
17
+ { sub: userId, typ: 'email-verify', email },
18
+ config.verification.expiration
19
+ )
20
+
21
+ const verifyUrl = `${config.prefix}/email/verify/${encodeURIComponent(token)}`
22
+
23
+ await mail.to(email)
24
+ .subject('Verify Your Email')
25
+ .template('bastion.verify-email', { verifyUrl, expiration: config.verification.expiration })
26
+ .send()
27
+ }
28
+
29
+ /** POST /email/send — resend verification email. */
30
+ export async function sendVerificationHandler(ctx: Context): Promise<Response> {
31
+ const user = ctx.get('user')
32
+ const actions = BastionManager.actions
33
+
34
+ if (actions.isEmailVerified!(user)) {
35
+ return ctx.json({ message: 'Email already verified.' })
36
+ }
37
+
38
+ await sendVerificationEmail(user)
39
+ return ctx.json({ message: 'Verification email sent.' })
40
+ }
41
+
42
+ /** GET /email/verify/:token — verify the email. */
43
+ export async function verifyEmailHandler(ctx: Context): Promise<Response> {
44
+ const token = ctx.params.token as string | undefined
45
+ if (!token) {
46
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
47
+ }
48
+
49
+ let payload: { sub: string | number; typ: string; email: string }
50
+ try {
51
+ payload = verifySignedToken(token)
52
+ } catch {
53
+ return ctx.json({ message: 'Invalid or expired verification link.' }, 422)
54
+ }
55
+
56
+ if (payload.typ !== 'email-verify') {
57
+ return ctx.json({ message: 'Invalid token type.' }, 422)
58
+ }
59
+
60
+ const user = await BastionManager.actions.findById(payload.sub)
61
+ if (!user) {
62
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
63
+ }
64
+
65
+ // Verify the email still matches
66
+ if (BastionManager.actions.emailOf(user) !== payload.email) {
67
+ return ctx.json({ message: 'Invalid verification link.' }, 422)
68
+ }
69
+
70
+ const actions = BastionManager.actions
71
+
72
+ if (actions.isEmailVerified!(user)) {
73
+ return ctx.json({ message: 'Email already verified.' })
74
+ }
75
+
76
+ await actions.markEmailVerified!(user)
77
+
78
+ if (Emitter.listenerCount(BastionEvents.EMAIL_VERIFIED) > 0) {
79
+ Emitter.emit(BastionEvents.EMAIL_VERIFIED, { user, ctx }).catch(() => {})
80
+ }
81
+
82
+ return ctx.json({ message: 'Email verified.' })
83
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,70 @@
1
+ import BastionManager from './bastion_manager.ts'
2
+ import { createSignedToken, verifySignedToken } from './tokens.ts'
3
+ import {
4
+ generateSecret,
5
+ totpUri,
6
+ verifyTotp,
7
+ base32Decode,
8
+ generateRecoveryCodes,
9
+ } from './totp.ts'
10
+
11
+ /**
12
+ * Bastion helper — convenience API for auth flow utilities.
13
+ *
14
+ * @example
15
+ * import { bastion } from '@stravigor/bastion'
16
+ *
17
+ * const token = bastion.signedToken({ sub: user.id, typ: 'custom' }, 60)
18
+ * const payload = bastion.verifyToken(token)
19
+ *
20
+ * const { secret, qrUri } = bastion.generateTwoFactorSecret(user)
21
+ * const valid = await bastion.verifyTwoFactorCode(secret, code)
22
+ */
23
+ export const bastion = {
24
+ /** Check if a feature is enabled. */
25
+ hasFeature(feature: string): boolean {
26
+ return BastionManager.hasFeature(feature as any)
27
+ },
28
+
29
+ /** Create a signed, encrypted token with an expiration. */
30
+ signedToken(data: { sub: string | number; typ: string; [key: string]: unknown }, expiresInMinutes: number): string {
31
+ return createSignedToken(data, expiresInMinutes)
32
+ },
33
+
34
+ /** Verify and decode a signed token. Throws if expired or tampered. */
35
+ verifyToken<T extends Record<string, unknown> = Record<string, unknown>>(token: string): T {
36
+ return verifySignedToken<T>(token)
37
+ },
38
+
39
+ /** Generate a TOTP secret and QR URI for the given user. */
40
+ generateTwoFactorSecret(user: unknown): { secret: string; qrUri: string } {
41
+ const config = BastionManager.config.twoFactor
42
+ const { base32 } = generateSecret()
43
+ const email = BastionManager.actions.emailOf(user)
44
+
45
+ const uri = totpUri({
46
+ secret: base32,
47
+ issuer: config.issuer,
48
+ account: email,
49
+ digits: config.digits,
50
+ period: config.period,
51
+ })
52
+
53
+ return { secret: base32, qrUri: uri }
54
+ },
55
+
56
+ /** Verify a TOTP code against a base32 secret. */
57
+ async verifyTwoFactorCode(base32Secret: string, code: string): Promise<boolean> {
58
+ const config = BastionManager.config.twoFactor
59
+ const secretBytes = base32Decode(base32Secret)
60
+ return verifyTotp(secretBytes, code, {
61
+ digits: config.digits,
62
+ period: config.period,
63
+ })
64
+ },
65
+
66
+ /** Generate a set of single-use recovery codes. */
67
+ generateRecoveryCodes(count?: number): string[] {
68
+ return generateRecoveryCodes(count ?? BastionManager.config.twoFactor.recoveryCodes)
69
+ },
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Manager & provider
2
+ export { default, default as BastionManager } from './bastion_manager.ts'
3
+ export { default as BastionProvider } from './bastion_provider.ts'
4
+
5
+ // Helper
6
+ export { bastion } from './helpers.ts'
7
+
8
+ // Actions
9
+ export { defineActions } from './actions.ts'
10
+
11
+ // Middleware
12
+ export { verified } from './middleware/verified.ts'
13
+ export { confirmed } from './middleware/confirmed.ts'
14
+ export { twoFactorChallenge } from './middleware/two_factor_challenge.ts'
15
+
16
+ // Handlers (for manual route registration)
17
+ export { registerHandler } from './handlers/register.ts'
18
+ export { loginHandler } from './handlers/login.ts'
19
+ export { logoutHandler } from './handlers/logout.ts'
20
+ export { forgotPasswordHandler } from './handlers/forgot_password.ts'
21
+ export { resetPasswordHandler } from './handlers/reset_password.ts'
22
+ export { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
23
+ export {
24
+ enableTwoFactorHandler,
25
+ confirmTwoFactorHandler,
26
+ disableTwoFactorHandler,
27
+ twoFactorChallengeHandler,
28
+ } from './handlers/two_factor.ts'
29
+ export { confirmPasswordHandler } from './handlers/confirm_password.ts'
30
+ export { updatePasswordHandler } from './handlers/update_password.ts'
31
+ export { updateProfileHandler } from './handlers/update_profile.ts'
32
+
33
+ // Errors
34
+ export { BastionError, MissingActionError, ValidationError } from './errors.ts'
35
+
36
+ // Types
37
+ export type {
38
+ Feature,
39
+ BastionActions,
40
+ BastionConfig,
41
+ BastionEvent,
42
+ RegistrationData,
43
+ RateLimitConfig,
44
+ } from './types.ts'
45
+ export { BastionEvents } from './types.ts'
46
+
47
+ // TOTP utilities
48
+ export { generateSecret, totpUri, verifyTotp, generateRecoveryCodes, base32Encode, base32Decode } from './totp.ts'
49
+
50
+ // Token utilities
51
+ export { createSignedToken, verifySignedToken } from './tokens.ts'
@@ -0,0 +1,28 @@
1
+ import type Session from '@stravigor/core/session/session'
2
+ import type { Middleware } from '@stravigor/core/http/middleware'
3
+ import BastionManager from '../bastion_manager.ts'
4
+
5
+ /**
6
+ * Require the user to have confirmed their password recently.
7
+ * Returns 423 (Locked) if the confirmation has expired.
8
+ *
9
+ * @example
10
+ * router.delete('/account', auth(), confirmed(), deleteAccountHandler)
11
+ */
12
+ export function confirmed(): Middleware {
13
+ return (ctx, next) => {
14
+ const session = ctx.get<Session>('session')
15
+ const confirmedAt = session.get<number>('_bastion_confirmed_at')
16
+
17
+ if (!confirmedAt) {
18
+ return ctx.json({ message: 'Password confirmation required.' }, 423)
19
+ }
20
+
21
+ const timeout = BastionManager.config.confirmation.timeout * 1000
22
+ if (Date.now() - confirmedAt > timeout) {
23
+ return ctx.json({ message: 'Password confirmation has expired.' }, 423)
24
+ }
25
+
26
+ return next()
27
+ }
28
+ }
@@ -0,0 +1,32 @@
1
+ import type Session from '@stravigor/core/session/session'
2
+ import type { Middleware } from '@stravigor/core/http/middleware'
3
+ import BastionManager from '../bastion_manager.ts'
4
+
5
+ /**
6
+ * Require a completed two-factor challenge for the current session.
7
+ * Returns 403 if the user has 2FA enabled but hasn't completed the challenge.
8
+ *
9
+ * Useful for protecting sensitive routes beyond the initial login.
10
+ *
11
+ * @example
12
+ * router.post('/transfer', auth(), twoFactorChallenge(), transferHandler)
13
+ */
14
+ export function twoFactorChallenge(): Middleware {
15
+ return (ctx, next) => {
16
+ const user = ctx.get('user')
17
+ const actions = BastionManager.actions
18
+
19
+ // If 2FA is not enabled for this user, let them through
20
+ if (!actions.twoFactorSecretOf) return next()
21
+ const secret = actions.twoFactorSecretOf(user)
22
+ if (!secret) return next()
23
+
24
+ // Check if there's still a pending 2FA challenge in the session
25
+ const session = ctx.get<Session>('session')
26
+ if (session.has('_bastion_2fa_email')) {
27
+ return ctx.json({ message: 'Two-factor authentication required.' }, 403)
28
+ }
29
+
30
+ return next()
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ import type { Middleware } from '@stravigor/core/http/middleware'
2
+ import BastionManager from '../bastion_manager.ts'
3
+
4
+ /**
5
+ * Require the authenticated user to have a verified email address.
6
+ * Returns 403 if the email is not verified.
7
+ *
8
+ * @example
9
+ * router.group({ middleware: [auth(), verified()] }, r => {
10
+ * r.get('/dashboard', dashboardHandler)
11
+ * })
12
+ */
13
+ export function verified(): Middleware {
14
+ return (ctx, next) => {
15
+ const user = ctx.get('user')
16
+ const actions = BastionManager.actions
17
+
18
+ if (!actions.isEmailVerified!(user)) {
19
+ return ctx.json({ message: 'Email not verified.' }, 403)
20
+ }
21
+
22
+ return next()
23
+ }
24
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { encrypt } from '@stravigor/core/encryption'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Signed token payloads
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface TokenPayload {
8
+ /** User identifier. */
9
+ sub: string | number
10
+ /** Token purpose. */
11
+ typ: string
12
+ /** Issued at (unix ms). */
13
+ iat: number
14
+ /** Expiration (minutes from iat). */
15
+ exp: number
16
+ /** Extra data. */
17
+ [key: string]: unknown
18
+ }
19
+
20
+ /**
21
+ * Create a signed, encrypted token using `encrypt.seal()`.
22
+ * Tamper-proof and opaque — the user cannot read or modify the payload.
23
+ *
24
+ * @param data - The payload to sign.
25
+ * @param expiresInMinutes - Token lifetime in minutes.
26
+ */
27
+ export function createSignedToken(
28
+ data: { sub: string | number; typ: string; [key: string]: unknown },
29
+ expiresInMinutes: number
30
+ ): string {
31
+ const payload: TokenPayload = {
32
+ ...data,
33
+ iat: Date.now(),
34
+ exp: expiresInMinutes,
35
+ }
36
+ return encrypt.seal(payload)
37
+ }
38
+
39
+ /**
40
+ * Verify and decode a signed token. Throws if expired or tampered.
41
+ *
42
+ * @returns The original payload.
43
+ * @throws If the token is invalid, expired, or tampered.
44
+ */
45
+ export function verifySignedToken<T = TokenPayload>(token: string): T {
46
+ const payload = encrypt.unseal<TokenPayload>(token)
47
+
48
+ if (!payload || typeof payload !== 'object' || !payload.iat) {
49
+ throw new Error('Invalid token payload.')
50
+ }
51
+
52
+ if (payload.exp) {
53
+ const expiresAt = payload.iat + payload.exp * 60_000
54
+ if (Date.now() > expiresAt) {
55
+ throw new Error('Token has expired.')
56
+ }
57
+ }
58
+
59
+ return payload as unknown as T
60
+ }