@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/src/totp.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * TOTP (Time-Based One-Time Password) implementation — RFC 6238.
3
+ * Pure Bun crypto, zero external dependencies.
4
+ */
5
+
6
+ import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Base32 encoding/decoding (RFC 4648)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
13
+
14
+ export function base32Encode(buffer: Uint8Array): string {
15
+ let result = ''
16
+ let bits = 0
17
+ let value = 0
18
+
19
+ for (const byte of buffer) {
20
+ value = (value << 8) | byte
21
+ bits += 8
22
+ while (bits >= 5) {
23
+ bits -= 5
24
+ result += BASE32_ALPHABET[(value >>> bits) & 0x1f]!
25
+ }
26
+ }
27
+
28
+ if (bits > 0) {
29
+ result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]!
30
+ }
31
+
32
+ return result
33
+ }
34
+
35
+ export function base32Decode(encoded: string): Uint8Array {
36
+ const cleaned = encoded.replace(/=+$/, '').toUpperCase()
37
+ const bytes: number[] = []
38
+ let bits = 0
39
+ let value = 0
40
+
41
+ for (const char of cleaned) {
42
+ const index = BASE32_ALPHABET.indexOf(char)
43
+ if (index === -1) continue
44
+ value = (value << 5) | index
45
+ bits += 5
46
+ if (bits >= 8) {
47
+ bits -= 8
48
+ bytes.push((value >>> bits) & 0xff)
49
+ }
50
+ }
51
+
52
+ return new Uint8Array(bytes)
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // HOTP — RFC 4226
57
+ // ---------------------------------------------------------------------------
58
+
59
+ async function hotp(secret: Uint8Array, counter: bigint, digits: number): Promise<string> {
60
+ // Counter as 8-byte big-endian buffer
61
+ const counterBuffer = new ArrayBuffer(8)
62
+ const view = new DataView(counterBuffer)
63
+ view.setBigUint64(0, counter)
64
+
65
+ // HMAC-SHA1
66
+ const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, [
67
+ 'sign',
68
+ ])
69
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuffer))
70
+
71
+ // Dynamic truncation
72
+ const offset = mac[19]! & 0x0f
73
+ const code =
74
+ ((mac[offset]! & 0x7f) << 24) |
75
+ ((mac[offset + 1]! & 0xff) << 16) |
76
+ ((mac[offset + 2]! & 0xff) << 8) |
77
+ (mac[offset + 3]! & 0xff)
78
+
79
+ return String(code % 10 ** digits).padStart(digits, '0')
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // TOTP — RFC 6238
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface TotpOptions {
87
+ digits?: number
88
+ period?: number
89
+ /** Number of time steps to check before/after current (handles clock drift). */
90
+ window?: number
91
+ }
92
+
93
+ /** Generate a TOTP code for the given secret at the current time. */
94
+ export async function generateTotp(
95
+ secret: Uint8Array,
96
+ options: TotpOptions = {}
97
+ ): Promise<string> {
98
+ const { digits = 6, period = 30 } = options
99
+ const counter = BigInt(Math.floor(Date.now() / 1000 / period))
100
+ return hotp(secret, counter, digits)
101
+ }
102
+
103
+ /** Verify a TOTP code, allowing for clock drift within the window. */
104
+ export async function verifyTotp(
105
+ secret: Uint8Array,
106
+ code: string,
107
+ options: TotpOptions = {}
108
+ ): Promise<boolean> {
109
+ const { digits = 6, period = 30, window = 1 } = options
110
+ const now = BigInt(Math.floor(Date.now() / 1000 / period))
111
+
112
+ for (let i = -window; i <= window; i++) {
113
+ const expected = await hotp(secret, now + BigInt(i), digits)
114
+ if (timingSafeEqual(code, expected)) return true
115
+ }
116
+
117
+ return false
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Secret generation
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** Generate a random TOTP secret (20 bytes, returned as base32). */
125
+ export function generateSecret(): { raw: Uint8Array; base32: string } {
126
+ const raw = crypto.getRandomValues(new Uint8Array(20))
127
+ return { raw, base32: base32Encode(raw) }
128
+ }
129
+
130
+ /**
131
+ * Build an `otpauth://` URI for QR code generation.
132
+ * Compatible with Google Authenticator, Authy, 1Password, etc.
133
+ */
134
+ export function totpUri(options: {
135
+ secret: string // base32
136
+ issuer: string
137
+ account: string
138
+ digits?: number
139
+ period?: number
140
+ }): string {
141
+ const { secret, issuer, account, digits = 6, period = 30 } = options
142
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`
143
+ const params = new URLSearchParams({
144
+ secret,
145
+ issuer,
146
+ algorithm: 'SHA1',
147
+ digits: String(digits),
148
+ period: String(period),
149
+ })
150
+ return `otpauth://totp/${label}?${params}`
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Recovery codes
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Generate a set of single-use recovery codes (8-char hex each). */
158
+ export function generateRecoveryCodes(count: number): string[] {
159
+ const codes: string[] = []
160
+ for (let i = 0; i < count; i++) {
161
+ const bytes = crypto.getRandomValues(new Uint8Array(4))
162
+ codes.push(Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''))
163
+ }
164
+ return codes
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Timing-safe string comparison
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function timingSafeEqual(a: string, b: string): boolean {
172
+ if (a.length !== b.length) return false
173
+ return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b))
174
+ }
package/src/types.ts ADDED
@@ -0,0 +1,135 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Feature flags
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type Feature =
8
+ | 'registration'
9
+ | 'login'
10
+ | 'logout'
11
+ | 'password-reset'
12
+ | 'email-verification'
13
+ | 'two-factor'
14
+ | 'password-confirmation'
15
+ | 'update-password'
16
+ | 'update-profile'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Actions — the user-provided contract
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface RegistrationData {
23
+ name: string
24
+ email: string
25
+ password: string
26
+ [key: string]: unknown
27
+ }
28
+
29
+ export interface BastionActions<TUser = unknown> {
30
+ /** Create and persist a new user. Password is raw — hash it yourself. */
31
+ createUser(data: RegistrationData): Promise<TUser>
32
+
33
+ /** Find a user by email address. Return null if not found. */
34
+ findByEmail(email: string): Promise<TUser | null>
35
+
36
+ /** Find a user by primary key. Return null if not found. */
37
+ findById(id: string | number): Promise<TUser | null>
38
+
39
+ /** Return the stored password hash for verification. */
40
+ passwordHashOf(user: TUser): string
41
+
42
+ /** Return the user's email address. */
43
+ emailOf(user: TUser): string
44
+
45
+ /** Persist a new password. Password is raw — hash it yourself. */
46
+ updatePassword(user: TUser, newPassword: string): Promise<void>
47
+
48
+ // ─── Email verification (required when feature is enabled) ───────────
49
+
50
+ /** Whether the user has verified their email. */
51
+ isEmailVerified?(user: TUser): boolean
52
+
53
+ /** Mark the user's email as verified. */
54
+ markEmailVerified?(user: TUser): Promise<void>
55
+
56
+ // ─── Two-factor authentication (required when feature is enabled) ────
57
+
58
+ /** Return the TOTP secret, or null if 2FA is not enabled. */
59
+ twoFactorSecretOf?(user: TUser): string | null
60
+
61
+ /** Persist the TOTP secret (null to clear). */
62
+ setTwoFactorSecret?(user: TUser, secret: string | null): Promise<void>
63
+
64
+ /** Return the user's recovery codes. */
65
+ recoveryCodesOf?(user: TUser): string[]
66
+
67
+ /** Persist new recovery codes. */
68
+ setRecoveryCodes?(user: TUser, codes: string[]): Promise<void>
69
+
70
+ // ─── Profile update (required when feature is enabled) ───────────────
71
+
72
+ /** Update the user's profile fields. */
73
+ updateProfile?(user: TUser, data: Record<string, unknown>): Promise<void>
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Configuration
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export interface RateLimitConfig {
81
+ /** Maximum requests in the window. */
82
+ max: number
83
+ /** Window duration in seconds. */
84
+ window: number
85
+ }
86
+
87
+ export interface BastionConfig {
88
+ features: Feature[]
89
+ prefix: string
90
+ mode: 'session' | 'token'
91
+ rateLimit: {
92
+ login: RateLimitConfig
93
+ register: RateLimitConfig
94
+ forgotPassword: RateLimitConfig
95
+ verifyEmail: RateLimitConfig
96
+ twoFactor: RateLimitConfig
97
+ }
98
+ passwords: {
99
+ expiration: number // minutes
100
+ }
101
+ verification: {
102
+ expiration: number // minutes
103
+ }
104
+ confirmation: {
105
+ timeout: number // seconds
106
+ }
107
+ twoFactor: {
108
+ issuer: string
109
+ digits: number
110
+ period: number
111
+ recoveryCodes: number
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Events
117
+ // ---------------------------------------------------------------------------
118
+
119
+ export interface BastionEvent<TUser = unknown> {
120
+ user: TUser
121
+ ctx: Context
122
+ }
123
+
124
+ export const BastionEvents = {
125
+ REGISTERED: 'bastion:registered',
126
+ LOGIN: 'bastion:login',
127
+ LOGOUT: 'bastion:logout',
128
+ PASSWORD_RESET: 'bastion:password-reset',
129
+ EMAIL_VERIFIED: 'bastion:email-verified',
130
+ TWO_FACTOR_ENABLED: 'bastion:two-factor-enabled',
131
+ TWO_FACTOR_DISABLED: 'bastion:two-factor-disabled',
132
+ PASSWORD_CONFIRMED: 'bastion:password-confirmed',
133
+ PASSWORD_UPDATED: 'bastion:password-updated',
134
+ PROFILE_UPDATED: 'bastion:profile-updated',
135
+ } as const
@@ -0,0 +1,83 @@
1
+ import { defineActions } from '@stravigor/bastion'
2
+ import { encrypt } from '@stravigor/core/encryption'
3
+ // import { User } from '../models/user'
4
+
5
+ /**
6
+ * Bastion actions — tell Bastion how your User model works.
7
+ *
8
+ * These are the only functions you need to implement. Bastion handles
9
+ * the routing, token generation, rate limiting, and event emission.
10
+ */
11
+ export default defineActions({
12
+ // ── Core (required) ──────────────────────────────────────────────────
13
+
14
+ async createUser(data) {
15
+ // return await User.create({
16
+ // name: data.name,
17
+ // email: data.email,
18
+ // password: await encrypt.hash(data.password),
19
+ // })
20
+ throw new Error('Implement createUser in actions/bastion.ts')
21
+ },
22
+
23
+ async findByEmail(email) {
24
+ // return await User.query().where('email', email).first()
25
+ throw new Error('Implement findByEmail in actions/bastion.ts')
26
+ },
27
+
28
+ async findById(id) {
29
+ // return await User.find(id)
30
+ throw new Error('Implement findById in actions/bastion.ts')
31
+ },
32
+
33
+ passwordHashOf(user: any) {
34
+ return user.password
35
+ },
36
+
37
+ emailOf(user: any) {
38
+ return user.email
39
+ },
40
+
41
+ async updatePassword(user: any, newPassword) {
42
+ user.password = await encrypt.hash(newPassword)
43
+ await user.save()
44
+ },
45
+
46
+ // ── Email verification (uncomment when feature is enabled) ───────────
47
+
48
+ // isEmailVerified(user: any) {
49
+ // return user.emailVerifiedAt !== null
50
+ // },
51
+
52
+ // async markEmailVerified(user: any) {
53
+ // user.emailVerifiedAt = new Date()
54
+ // await user.save()
55
+ // },
56
+
57
+ // ── Two-factor authentication (uncomment when feature is enabled) ────
58
+
59
+ // twoFactorSecretOf(user: any) {
60
+ // return user.twoFactorSecret ?? null
61
+ // },
62
+
63
+ // async setTwoFactorSecret(user: any, secret) {
64
+ // user.twoFactorSecret = secret
65
+ // await user.save()
66
+ // },
67
+
68
+ // recoveryCodesOf(user: any) {
69
+ // return user.recoveryCodes ?? []
70
+ // },
71
+
72
+ // async setRecoveryCodes(user: any, codes) {
73
+ // user.recoveryCodes = codes
74
+ // await user.save()
75
+ // },
76
+
77
+ // ── Profile update (uncomment when feature is enabled) ───────────────
78
+
79
+ // async updateProfile(user: any, data) {
80
+ // Object.assign(user, data)
81
+ // await user.save()
82
+ // },
83
+ })
@@ -0,0 +1,55 @@
1
+ import { env } from '@stravigor/core/helpers'
2
+
3
+ export default {
4
+ // Toggle features on/off. Uncomment to enable.
5
+ features: [
6
+ 'registration',
7
+ 'login',
8
+ 'logout',
9
+ 'password-reset',
10
+ // 'email-verification',
11
+ // 'two-factor',
12
+ // 'password-confirmation',
13
+ // 'update-password',
14
+ // 'update-profile',
15
+ ],
16
+
17
+ // Route prefix — '' means routes live at /login, /register, etc.
18
+ prefix: '',
19
+
20
+ // Auth mode: 'session' (cookie-based) or 'token' (access tokens)
21
+ mode: 'session',
22
+
23
+ // Rate limiting per flow (max attempts per window in seconds)
24
+ rateLimit: {
25
+ login: { max: 5, window: 60 },
26
+ register: { max: 3, window: 60 },
27
+ forgotPassword: { max: 3, window: 60 },
28
+ verifyEmail: { max: 3, window: 60 },
29
+ twoFactor: { max: 5, window: 60 },
30
+ },
31
+
32
+ // Password reset link lifetime (minutes)
33
+ passwords: {
34
+ expiration: 60,
35
+ },
36
+
37
+ // Email verification link lifetime (minutes)
38
+ verification: {
39
+ expiration: 60,
40
+ },
41
+
42
+ // Password confirmation timeout (seconds) — how long before the user
43
+ // must re-enter their password for sensitive operations
44
+ confirmation: {
45
+ timeout: 10_800, // 3 hours
46
+ },
47
+
48
+ // Two-factor authentication (TOTP)
49
+ twoFactor: {
50
+ issuer: env('APP_NAME', 'Strav'),
51
+ digits: 6,
52
+ period: 30,
53
+ recoveryCodes: 8,
54
+ },
55
+ }
@@ -0,0 +1,26 @@
1
+ {{-- Password Reset Email --}}
2
+
3
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
4
+ <h2 style="color: #1a1a1a; margin-bottom: 24px;">Reset Your Password</h2>
5
+
6
+ <p style="color: #4a4a4a; line-height: 1.6;">
7
+ You requested a password reset. Click the button below to choose a new password.
8
+ </p>
9
+
10
+ <div style="text-align: center; margin: 32px 0;">
11
+ <a href="{{ resetUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600;">
12
+ Reset Password
13
+ </a>
14
+ </div>
15
+
16
+ <p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
17
+ This link will expire in {{ expiration }} minutes. If you didn't request a password reset, you can safely ignore this email.
18
+ </p>
19
+
20
+ <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;" />
21
+
22
+ <p style="color: #9ca3af; font-size: 12px;">
23
+ If the button doesn't work, copy and paste this URL into your browser:<br />
24
+ <span style="color: #6b7280;">{{ resetUrl }}</span>
25
+ </p>
26
+ </div>
@@ -0,0 +1,26 @@
1
+ {{-- Email Verification --}}
2
+
3
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
4
+ <h2 style="color: #1a1a1a; margin-bottom: 24px;">Verify Your Email</h2>
5
+
6
+ <p style="color: #4a4a4a; line-height: 1.6;">
7
+ Thanks for signing up! Please verify your email address by clicking the button below.
8
+ </p>
9
+
10
+ <div style="text-align: center; margin: 32px 0;">
11
+ <a href="{{ verifyUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-weight: 600;">
12
+ Verify Email
13
+ </a>
14
+ </div>
15
+
16
+ <p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
17
+ This link will expire in {{ expiration }} minutes. If you didn't create an account, you can safely ignore this email.
18
+ </p>
19
+
20
+ <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;" />
21
+
22
+ <p style="color: #9ca3af; font-size: 12px;">
23
+ If the button doesn't work, copy and paste this URL into your browser:<br />
24
+ <span style="color: #6b7280;">{{ verifyUrl }}</span>
25
+ </p>
26
+ </div>
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }