@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.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@stravigor/bastion",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Headless authentication flows for the Strav framework",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./*": "./src/*.ts"
10
+ },
11
+ "files": ["src/", "stubs/", "package.json", "tsconfig.json"],
12
+ "peerDependencies": {
13
+ "@stravigor/core": "0.3.3"
14
+ },
15
+ "scripts": {
16
+ "test": "bun test tests/",
17
+ "typecheck": "tsc --noEmit"
18
+ }
19
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { BastionActions } from './types.ts'
2
+
3
+ /**
4
+ * Type-safe identity function for defining Bastion actions.
5
+ * Zero runtime cost — just provides autocompletion and type checking.
6
+ *
7
+ * @example
8
+ * import { defineActions } from '@stravigor/bastion'
9
+ * import { User } from '../models/user'
10
+ *
11
+ * export default defineActions<User>({
12
+ * createUser: async (data) => User.create({ ... }),
13
+ * findByEmail: (email) => User.query().where('email', email).first(),
14
+ * findById: (id) => User.find(id),
15
+ * passwordHashOf: (user) => user.password,
16
+ * emailOf: (user) => user.email,
17
+ * updatePassword: async (user, pw) => { user.password = await encrypt.hash(pw); await user.save() },
18
+ * })
19
+ */
20
+ export function defineActions<TUser = unknown>(
21
+ actions: BastionActions<TUser>
22
+ ): BastionActions<TUser> {
23
+ return actions
24
+ }
@@ -0,0 +1,209 @@
1
+ import { inject } from '@stravigor/core/core'
2
+ import type Configuration from '@stravigor/core/config/configuration'
3
+ import type Router from '@stravigor/core/http/router'
4
+ import { compose } from '@stravigor/core/http/middleware'
5
+ import type { Handler, Middleware } from '@stravigor/core/http/middleware'
6
+ import { rateLimit } from '@stravigor/core/http/rate_limit'
7
+ import { auth } from '@stravigor/core/auth/middleware/authenticate'
8
+ import { guest } from '@stravigor/core/auth/middleware/guest'
9
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
10
+ import { MissingActionError } from './errors.ts'
11
+ import type { BastionActions, BastionConfig, Feature } from './types.ts'
12
+ import { registerHandler } from './handlers/register.ts'
13
+ import { loginHandler } from './handlers/login.ts'
14
+ import { logoutHandler } from './handlers/logout.ts'
15
+ import { forgotPasswordHandler } from './handlers/forgot_password.ts'
16
+ import { resetPasswordHandler } from './handlers/reset_password.ts'
17
+ import { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
18
+ import {
19
+ enableTwoFactorHandler,
20
+ confirmTwoFactorHandler,
21
+ disableTwoFactorHandler,
22
+ twoFactorChallengeHandler,
23
+ } from './handlers/two_factor.ts'
24
+ import { confirmPasswordHandler } from './handlers/confirm_password.ts'
25
+ import { updatePasswordHandler } from './handlers/update_password.ts'
26
+ import { updateProfileHandler } from './handlers/update_profile.ts'
27
+ import { confirmed } from './middleware/confirmed.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Default config
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const DEFAULTS: BastionConfig = {
34
+ features: ['registration', 'login', 'logout', 'password-reset'],
35
+ prefix: '',
36
+ mode: 'session',
37
+ rateLimit: {
38
+ login: { max: 5, window: 60 },
39
+ register: { max: 3, window: 60 },
40
+ forgotPassword: { max: 3, window: 60 },
41
+ verifyEmail: { max: 3, window: 60 },
42
+ twoFactor: { max: 5, window: 60 },
43
+ },
44
+ passwords: { expiration: 60 },
45
+ verification: { expiration: 60 },
46
+ confirmation: { timeout: 10_800 },
47
+ twoFactor: { issuer: 'Strav', digits: 6, period: 30, recoveryCodes: 8 },
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Manager
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Wrap a handler with middleware via compose. */
55
+ function withMiddleware(mw: Middleware[], handler: Handler): Handler {
56
+ return mw.length > 0 ? compose(mw, handler) : handler
57
+ }
58
+
59
+ @inject
60
+ export default class BastionManager {
61
+ private static _config: BastionConfig
62
+ private static _actions: BastionActions
63
+
64
+ constructor(config: Configuration) {
65
+ const raw = config.get('bastion', {}) as Partial<BastionConfig>
66
+ BastionManager._config = { ...DEFAULTS, ...raw } as BastionConfig
67
+ }
68
+
69
+ // ── Accessors ────────────────────────────────────────────────────────
70
+
71
+ static get config(): BastionConfig {
72
+ if (!BastionManager._config) {
73
+ throw new ConfigurationError('BastionManager not configured. Resolve it through the container first.')
74
+ }
75
+ return BastionManager._config
76
+ }
77
+
78
+ static get actions(): BastionActions {
79
+ if (!BastionManager._actions) {
80
+ throw new ConfigurationError('Bastion actions not set. Pass actions to BastionProvider.')
81
+ }
82
+ return BastionManager._actions
83
+ }
84
+
85
+ /** Set the user-defined actions contract. */
86
+ static useActions(actions: BastionActions): void {
87
+ BastionManager._actions = actions
88
+ }
89
+
90
+ /** Check whether a feature is enabled. */
91
+ static hasFeature(feature: Feature): boolean {
92
+ return BastionManager._config.features.includes(feature)
93
+ }
94
+
95
+ // ── Validation ───────────────────────────────────────────────────────
96
+
97
+ /** Verify that all required actions are provided for enabled features. */
98
+ static validateActions(): void {
99
+ const a = BastionManager._actions
100
+ const has = (f: Feature) => BastionManager.hasFeature(f)
101
+
102
+ if (has('email-verification')) {
103
+ if (!a.isEmailVerified) throw new MissingActionError('isEmailVerified', 'email-verification')
104
+ if (!a.markEmailVerified) throw new MissingActionError('markEmailVerified', 'email-verification')
105
+ }
106
+
107
+ if (has('two-factor')) {
108
+ if (!a.twoFactorSecretOf) throw new MissingActionError('twoFactorSecretOf', 'two-factor')
109
+ if (!a.setTwoFactorSecret) throw new MissingActionError('setTwoFactorSecret', 'two-factor')
110
+ if (!a.recoveryCodesOf) throw new MissingActionError('recoveryCodesOf', 'two-factor')
111
+ if (!a.setRecoveryCodes) throw new MissingActionError('setRecoveryCodes', 'two-factor')
112
+ }
113
+
114
+ if (has('update-profile')) {
115
+ if (!a.updateProfile) throw new MissingActionError('updateProfile', 'update-profile')
116
+ }
117
+ }
118
+
119
+ // ── Route registration ───────────────────────────────────────────────
120
+
121
+ /** Build a rate limit middleware from a config key. */
122
+ private static rl(key: keyof BastionConfig['rateLimit']): Middleware {
123
+ const cfg = BastionManager._config.rateLimit[key]
124
+ return rateLimit({ max: cfg.max, window: cfg.window * 1000 })
125
+ }
126
+
127
+ /**
128
+ * Register all Bastion routes on the given router.
129
+ *
130
+ * @param router - The router instance.
131
+ * @param options - `only` or `except` to selectively register routes.
132
+ */
133
+ static routes(
134
+ router: Router,
135
+ options?: { only?: Feature[]; except?: Feature[] }
136
+ ): void {
137
+ const enabled = (f: Feature): boolean => {
138
+ if (!BastionManager.hasFeature(f)) return false
139
+ if (options?.only) return options.only.includes(f)
140
+ if (options?.except) return !options.except.includes(f)
141
+ return true
142
+ }
143
+
144
+ const prefix = BastionManager._config.prefix
145
+
146
+ router.group({ prefix }, r => {
147
+ if (enabled('registration')) {
148
+ r.post('/register', withMiddleware(
149
+ [guest(), BastionManager.rl('register')], registerHandler
150
+ ))
151
+ }
152
+
153
+ if (enabled('login')) {
154
+ r.post('/login', withMiddleware(
155
+ [guest(), BastionManager.rl('login')], loginHandler
156
+ ))
157
+ }
158
+
159
+ if (enabled('logout')) {
160
+ r.post('/logout', withMiddleware([auth()], logoutHandler))
161
+ }
162
+
163
+ if (enabled('password-reset')) {
164
+ r.post('/forgot-password', withMiddleware(
165
+ [guest(), BastionManager.rl('forgotPassword')], forgotPasswordHandler
166
+ ))
167
+ r.post('/reset-password', withMiddleware([guest()], resetPasswordHandler))
168
+ }
169
+
170
+ if (enabled('email-verification')) {
171
+ r.post('/email/send', withMiddleware(
172
+ [auth(), BastionManager.rl('verifyEmail')], sendVerificationHandler
173
+ ))
174
+ r.get('/email/verify/:token', verifyEmailHandler)
175
+ }
176
+
177
+ if (enabled('two-factor')) {
178
+ r.post('/two-factor/enable', withMiddleware(
179
+ [auth(), confirmed()], enableTwoFactorHandler
180
+ ))
181
+ r.post('/two-factor/confirm', withMiddleware([auth()], confirmTwoFactorHandler))
182
+ r.delete('/two-factor', withMiddleware(
183
+ [auth(), confirmed()], disableTwoFactorHandler
184
+ ))
185
+ r.post('/two-factor/challenge', withMiddleware(
186
+ [BastionManager.rl('twoFactor')], twoFactorChallengeHandler
187
+ ))
188
+ }
189
+
190
+ if (enabled('password-confirmation')) {
191
+ r.post('/confirm-password', withMiddleware([auth()], confirmPasswordHandler))
192
+ }
193
+
194
+ if (enabled('update-password')) {
195
+ r.put('/password', withMiddleware([auth()], updatePasswordHandler))
196
+ }
197
+
198
+ if (enabled('update-profile')) {
199
+ r.put('/profile', withMiddleware([auth()], updateProfileHandler))
200
+ }
201
+ })
202
+ }
203
+
204
+ /** Clear all state. For testing. */
205
+ static reset(): void {
206
+ BastionManager._config = undefined as any
207
+ BastionManager._actions = undefined as any
208
+ }
209
+ }
@@ -0,0 +1,25 @@
1
+ import { ServiceProvider } from '@stravigor/core/core'
2
+ import type { Application } from '@stravigor/core/core'
3
+ import Router from '@stravigor/core/http/router'
4
+ import BastionManager from './bastion_manager.ts'
5
+ import type { BastionActions } from './types.ts'
6
+
7
+ export default class BastionProvider extends ServiceProvider {
8
+ readonly name = 'bastion'
9
+ override readonly dependencies = ['auth', 'session', 'encryption', 'mail']
10
+
11
+ constructor(private actions: BastionActions) {
12
+ super()
13
+ }
14
+
15
+ override register(app: Application): void {
16
+ app.singleton(BastionManager)
17
+ }
18
+
19
+ override boot(app: Application): void {
20
+ app.resolve(BastionManager)
21
+ BastionManager.useActions(this.actions)
22
+ BastionManager.validateActions()
23
+ BastionManager.routes(app.resolve(Router))
24
+ }
25
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { StravError } from '@stravigor/core/exceptions/strav_error'
2
+
3
+ /** Base error for all Bastion errors. */
4
+ export class BastionError extends StravError {}
5
+
6
+ /** Thrown when a required action is missing for an enabled feature. */
7
+ export class MissingActionError extends BastionError {
8
+ constructor(action: string, feature: string) {
9
+ super(`Bastion action "${action}" is required when the "${feature}" feature is enabled.`)
10
+ }
11
+ }
12
+
13
+ /** Thrown when input validation fails. */
14
+ export class ValidationError extends BastionError {
15
+ constructor(
16
+ message: string,
17
+ public readonly errors: Record<string, string> = {}
18
+ ) {
19
+ super(message)
20
+ }
21
+ }
@@ -0,0 +1,32 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import type Session from '@stravigor/core/session/session'
3
+ import { encrypt } from '@stravigor/core/encryption'
4
+ import Emitter from '@stravigor/core/events/emitter'
5
+ import BastionManager from '../bastion_manager.ts'
6
+ import { BastionEvents } from '../types.ts'
7
+
8
+ export async function confirmPasswordHandler(ctx: Context): Promise<Response> {
9
+ const body = await ctx.body<{ password?: string }>()
10
+
11
+ if (!body.password) {
12
+ return ctx.json({ message: 'Password is required.' }, 422)
13
+ }
14
+
15
+ const user = ctx.get('user')
16
+ const hash = BastionManager.actions.passwordHashOf(user)
17
+ const valid = await encrypt.verify(body.password, hash)
18
+
19
+ if (!valid) {
20
+ return ctx.json({ message: 'Invalid password.' }, 422)
21
+ }
22
+
23
+ // Store confirmation timestamp in session
24
+ const session = ctx.get<Session>('session')
25
+ session.set('_bastion_confirmed_at', Date.now())
26
+
27
+ if (Emitter.listenerCount(BastionEvents.PASSWORD_CONFIRMED) > 0) {
28
+ Emitter.emit(BastionEvents.PASSWORD_CONFIRMED, { user, ctx }).catch(() => {})
29
+ }
30
+
31
+ return ctx.json({ message: 'Password confirmed.' })
32
+ }
@@ -0,0 +1,36 @@
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 BastionManager from '../bastion_manager.ts'
5
+ import { createSignedToken } from '../tokens.ts'
6
+
7
+ export async function forgotPasswordHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{ email?: string }>()
9
+
10
+ if (!body.email) {
11
+ return ctx.json({ message: 'Email is required.' }, 422)
12
+ }
13
+
14
+ // Always return success to prevent email enumeration
15
+ const user = await BastionManager.actions.findByEmail(body.email)
16
+
17
+ if (user) {
18
+ const config = BastionManager.config
19
+ const userId = extractUserId(user)
20
+ const email = BastionManager.actions.emailOf(user)
21
+
22
+ const token = createSignedToken(
23
+ { sub: userId, typ: 'password-reset', email },
24
+ config.passwords.expiration
25
+ )
26
+
27
+ const resetUrl = `${ctx.url.origin}${config.prefix}/reset-password?token=${encodeURIComponent(token)}`
28
+
29
+ await mail.to(email)
30
+ .subject('Reset Your Password')
31
+ .template('bastion.reset-password', { resetUrl, expiration: config.passwords.expiration })
32
+ .send()
33
+ }
34
+
35
+ return ctx.json({ message: 'If an account exists, a reset link has been sent.' })
36
+ }
@@ -0,0 +1,65 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import type Session from '@stravigor/core/session/session'
3
+ import AccessToken from '@stravigor/core/auth/access_token'
4
+ import { encrypt } from '@stravigor/core/encryption'
5
+ import Emitter from '@stravigor/core/events/emitter'
6
+ import BastionManager from '../bastion_manager.ts'
7
+ import { BastionEvents } from '../types.ts'
8
+
9
+ export async function loginHandler(ctx: Context): Promise<Response> {
10
+ const body = await ctx.body<{ email?: string; password?: string }>()
11
+ const { email, password } = body
12
+
13
+ // Validate
14
+ if (!email || !password) {
15
+ return ctx.json({ message: 'Email and password are required.' }, 422)
16
+ }
17
+
18
+ // Find user
19
+ const user = await BastionManager.actions.findByEmail(email)
20
+ if (!user) {
21
+ return ctx.json({ message: 'Invalid credentials.' }, 401)
22
+ }
23
+
24
+ // Verify password
25
+ const hash = BastionManager.actions.passwordHashOf(user)
26
+ const valid = await encrypt.verify(password, hash)
27
+ if (!valid) {
28
+ return ctx.json({ message: 'Invalid credentials.' }, 401)
29
+ }
30
+
31
+ // Two-factor challenge
32
+ if (BastionManager.hasFeature('two-factor') && BastionManager.actions.twoFactorSecretOf) {
33
+ const secret = BastionManager.actions.twoFactorSecretOf(user)
34
+ if (secret) {
35
+ // Store the user email in session for the challenge step
36
+ const session = ctx.get<Session>('session')
37
+ session.set('_bastion_2fa_email', email)
38
+ return ctx.json({ two_factor: true })
39
+ }
40
+ }
41
+
42
+ return completeLogin(ctx, user)
43
+ }
44
+
45
+ /** Finalize login — authenticate session or issue token. */
46
+ export async function completeLogin(ctx: Context, user: unknown): Promise<Response> {
47
+ const config = BastionManager.config
48
+
49
+ if (config.mode === 'session') {
50
+ const session = ctx.get<Session>('session')
51
+ session.authenticate(user)
52
+ await session.regenerate()
53
+ }
54
+
55
+ if (Emitter.listenerCount(BastionEvents.LOGIN) > 0) {
56
+ Emitter.emit(BastionEvents.LOGIN, { user, ctx }).catch(() => {})
57
+ }
58
+
59
+ if (config.mode === 'token') {
60
+ const { token, accessToken } = await AccessToken.create(user, 'login')
61
+ return ctx.json({ user, token, accessToken })
62
+ }
63
+
64
+ return ctx.json({ user })
65
+ }
@@ -0,0 +1,23 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import 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
+
7
+ export async function logoutHandler(ctx: Context): Promise<Response> {
8
+ const user = ctx.get('user')
9
+
10
+ if (Emitter.listenerCount(BastionEvents.LOGOUT) > 0) {
11
+ Emitter.emit(BastionEvents.LOGOUT, { user, ctx }).catch(() => {})
12
+ }
13
+
14
+ if (BastionManager.config.mode === 'session') {
15
+ const response = ctx.json({ message: 'Logged out.' })
16
+ return Session.destroy(ctx, response)
17
+ }
18
+
19
+ // Token mode: the client should discard the token.
20
+ // Optionally we could revoke the token here, but the auth middleware
21
+ // already attaches the accessToken to the context.
22
+ return ctx.json({ message: 'Logged out.' })
23
+ }
@@ -0,0 +1,70 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import type Session from '@stravigor/core/session/session'
3
+ import AccessToken from '@stravigor/core/auth/access_token'
4
+ import Emitter from '@stravigor/core/events/emitter'
5
+ import BastionManager from '../bastion_manager.ts'
6
+ import { ValidationError } from '../errors.ts'
7
+ import { BastionEvents } from '../types.ts'
8
+ import { sendVerificationEmail } from './verify_email.ts'
9
+
10
+ export async function registerHandler(ctx: Context): Promise<Response> {
11
+ const body = await ctx.body<Record<string, unknown>>()
12
+ const { name, email, password, password_confirmation } = body as {
13
+ name?: string
14
+ email?: string
15
+ password?: string
16
+ password_confirmation?: string
17
+ }
18
+
19
+ // Validate
20
+ const errors: Record<string, string> = {}
21
+ if (!name || typeof name !== 'string') errors.name = 'Name is required.'
22
+ if (!email || typeof email !== 'string') errors.email = 'Email is required.'
23
+ if (!password || typeof password !== 'string') errors.password = 'Password is required.'
24
+ else if (password.length < 8) errors.password = 'Password must be at least 8 characters.'
25
+ if (password !== password_confirmation) errors.password_confirmation = 'Passwords do not match.'
26
+
27
+ if (Object.keys(errors).length > 0) {
28
+ return ctx.json({ message: 'Validation failed.', errors }, 422)
29
+ }
30
+
31
+ // Check if email is already taken
32
+ const existing = await BastionManager.actions.findByEmail(email!)
33
+ if (existing) {
34
+ return ctx.json({ message: 'Validation failed.', errors: { email: 'Email already taken.' } }, 422)
35
+ }
36
+
37
+ // Create user
38
+ const user = await BastionManager.actions.createUser({
39
+ name: name!,
40
+ email: email!,
41
+ password: password!,
42
+ ...body,
43
+ })
44
+
45
+ const config = BastionManager.config
46
+
47
+ // Authenticate
48
+ if (config.mode === 'session') {
49
+ const session = ctx.get<Session>('session')
50
+ session.authenticate(user)
51
+ await session.regenerate()
52
+ }
53
+
54
+ // Emit
55
+ if (Emitter.listenerCount(BastionEvents.REGISTERED) > 0) {
56
+ Emitter.emit(BastionEvents.REGISTERED, { user, ctx }).catch(() => {})
57
+ }
58
+
59
+ // Auto-send verification email if feature enabled
60
+ if (BastionManager.hasFeature('email-verification')) {
61
+ sendVerificationEmail(user).catch(() => {})
62
+ }
63
+
64
+ if (config.mode === 'token') {
65
+ const { token, accessToken } = await AccessToken.create(user, 'registration')
66
+ return ctx.json({ user, token, accessToken }, 201)
67
+ }
68
+
69
+ return ctx.json({ user }, 201)
70
+ }
@@ -0,0 +1,56 @@
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 { verifySignedToken } from '../tokens.ts'
5
+ import { BastionEvents } from '../types.ts'
6
+
7
+ export async function resetPasswordHandler(ctx: Context): Promise<Response> {
8
+ const body = await ctx.body<{
9
+ token?: string
10
+ password?: string
11
+ password_confirmation?: string
12
+ }>()
13
+
14
+ // Validate input
15
+ if (!body.token) {
16
+ return ctx.json({ message: 'Token is required.' }, 422)
17
+ }
18
+ if (!body.password || body.password.length < 8) {
19
+ return ctx.json({ message: '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
+ // Verify token
26
+ let payload: { sub: string | number; typ: string; email: string }
27
+ try {
28
+ payload = verifySignedToken(body.token)
29
+ } catch {
30
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
31
+ }
32
+
33
+ if (payload.typ !== 'password-reset') {
34
+ return ctx.json({ message: 'Invalid token type.' }, 422)
35
+ }
36
+
37
+ // Find user
38
+ const user = await BastionManager.actions.findById(payload.sub)
39
+ if (!user) {
40
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
41
+ }
42
+
43
+ // Verify the email still matches (prevents token reuse after email change)
44
+ if (BastionManager.actions.emailOf(user) !== payload.email) {
45
+ return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
46
+ }
47
+
48
+ // Update password
49
+ await BastionManager.actions.updatePassword(user, body.password)
50
+
51
+ if (Emitter.listenerCount(BastionEvents.PASSWORD_RESET) > 0) {
52
+ Emitter.emit(BastionEvents.PASSWORD_RESET, { user, ctx }).catch(() => {})
53
+ }
54
+
55
+ return ctx.json({ message: 'Password has been reset.' })
56
+ }