@strav/jina 0.1.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 +73 -0
- package/package.json +27 -0
- package/src/actions.ts +22 -0
- package/src/errors.ts +21 -0
- package/src/handlers/confirm_password.ts +30 -0
- package/src/handlers/forgot_password.ts +37 -0
- package/src/handlers/login.ts +63 -0
- package/src/handlers/logout.ts +23 -0
- package/src/handlers/register.ts +72 -0
- package/src/handlers/reset_password.ts +56 -0
- package/src/handlers/two_factor.ts +171 -0
- package/src/handlers/update_password.ts +39 -0
- package/src/handlers/update_profile.ts +17 -0
- package/src/handlers/verify_email.ts +84 -0
- package/src/helpers.ts +67 -0
- package/src/index.ts +58 -0
- package/src/jina_manager.ts +200 -0
- package/src/jina_provider.ts +25 -0
- package/src/middleware/confirmed.ts +27 -0
- package/src/middleware/two_factor_challenge.ts +31 -0
- package/src/middleware/verified.ts +24 -0
- package/src/tokens.ts +60 -0
- package/src/totp.ts +171 -0
- package/src/types.ts +135 -0
- package/stubs/actions/jina.ts +83 -0
- package/stubs/config/jina.ts +55 -0
- package/stubs/emails/reset-password.strav +26 -0
- package/stubs/emails/verify-email.strav +26 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import { mail } from '@stravigor/signal'
|
|
3
|
+
import { extractUserId } from '@stravigor/database'
|
|
4
|
+
import type { Context } from '@stravigor/http'
|
|
5
|
+
import JinaManager from '../jina_manager.ts'
|
|
6
|
+
import { createSignedToken, verifySignedToken } from '../tokens.ts'
|
|
7
|
+
import { JinaEvents } 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 = JinaManager.config
|
|
12
|
+
const actions = JinaManager.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
|
|
24
|
+
.to(email)
|
|
25
|
+
.subject('Verify Your Email')
|
|
26
|
+
.template('jina.verify-email', { verifyUrl, expiration: config.verification.expiration })
|
|
27
|
+
.send()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** POST /email/send — resend verification email. */
|
|
31
|
+
export async function sendVerificationHandler(ctx: Context): Promise<Response> {
|
|
32
|
+
const user = ctx.get('user')
|
|
33
|
+
const actions = JinaManager.actions
|
|
34
|
+
|
|
35
|
+
if (actions.isEmailVerified!(user)) {
|
|
36
|
+
return ctx.json({ message: 'Email already verified.' })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await sendVerificationEmail(user)
|
|
40
|
+
return ctx.json({ message: 'Verification email sent.' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET /email/verify/:token — verify the email. */
|
|
44
|
+
export async function verifyEmailHandler(ctx: Context): Promise<Response> {
|
|
45
|
+
const token = ctx.params.token as string | undefined
|
|
46
|
+
if (!token) {
|
|
47
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let payload: { sub: string | number; typ: string; email: string }
|
|
51
|
+
try {
|
|
52
|
+
payload = verifySignedToken(token)
|
|
53
|
+
} catch {
|
|
54
|
+
return ctx.json({ message: 'Invalid or expired verification link.' }, 422)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (payload.typ !== 'email-verify') {
|
|
58
|
+
return ctx.json({ message: 'Invalid token type.' }, 422)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const user = await JinaManager.actions.findById(payload.sub)
|
|
62
|
+
if (!user) {
|
|
63
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Verify the email still matches
|
|
67
|
+
if (JinaManager.actions.emailOf(user) !== payload.email) {
|
|
68
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const actions = JinaManager.actions
|
|
72
|
+
|
|
73
|
+
if (actions.isEmailVerified!(user)) {
|
|
74
|
+
return ctx.json({ message: 'Email already verified.' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await actions.markEmailVerified!(user)
|
|
78
|
+
|
|
79
|
+
if (Emitter.listenerCount(JinaEvents.EMAIL_VERIFIED) > 0) {
|
|
80
|
+
Emitter.emit(JinaEvents.EMAIL_VERIFIED, { user, ctx }).catch(() => {})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return ctx.json({ message: 'Email verified.' })
|
|
84
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import JinaManager from './jina_manager.ts'
|
|
2
|
+
import { createSignedToken, verifySignedToken } from './tokens.ts'
|
|
3
|
+
import { generateSecret, totpUri, verifyTotp, base32Decode, generateRecoveryCodes } from './totp.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Jina helper — convenience API for auth flow utilities.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { jina } from '@stravigor/jina'
|
|
10
|
+
*
|
|
11
|
+
* const token = jina.signedToken({ sub: user.id, typ: 'custom' }, 60)
|
|
12
|
+
* const payload = jina.verifyToken(token)
|
|
13
|
+
*
|
|
14
|
+
* const { secret, qrUri } = jina.generateTwoFactorSecret(user)
|
|
15
|
+
* const valid = await jina.verifyTwoFactorCode(secret, code)
|
|
16
|
+
*/
|
|
17
|
+
export const jina = {
|
|
18
|
+
/** Check if a feature is enabled. */
|
|
19
|
+
hasFeature(feature: string): boolean {
|
|
20
|
+
return JinaManager.hasFeature(feature as any)
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/** Create a signed, encrypted token with an expiration. */
|
|
24
|
+
signedToken(
|
|
25
|
+
data: { sub: string | number; typ: string; [key: string]: unknown },
|
|
26
|
+
expiresInMinutes: number
|
|
27
|
+
): string {
|
|
28
|
+
return createSignedToken(data, expiresInMinutes)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/** Verify and decode a signed token. Throws if expired or tampered. */
|
|
32
|
+
verifyToken<T extends Record<string, unknown> = Record<string, unknown>>(token: string): T {
|
|
33
|
+
return verifySignedToken<T>(token)
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/** Generate a TOTP secret and QR URI for the given user. */
|
|
37
|
+
generateTwoFactorSecret(user: unknown): { secret: string; qrUri: string } {
|
|
38
|
+
const config = JinaManager.config.twoFactor
|
|
39
|
+
const { base32 } = generateSecret()
|
|
40
|
+
const email = JinaManager.actions.emailOf(user)
|
|
41
|
+
|
|
42
|
+
const uri = totpUri({
|
|
43
|
+
secret: base32,
|
|
44
|
+
issuer: config.issuer,
|
|
45
|
+
account: email,
|
|
46
|
+
digits: config.digits,
|
|
47
|
+
period: config.period,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return { secret: base32, qrUri: uri }
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** Verify a TOTP code against a base32 secret. */
|
|
54
|
+
async verifyTwoFactorCode(base32Secret: string, code: string): Promise<boolean> {
|
|
55
|
+
const config = JinaManager.config.twoFactor
|
|
56
|
+
const secretBytes = base32Decode(base32Secret)
|
|
57
|
+
return verifyTotp(secretBytes, code, {
|
|
58
|
+
digits: config.digits,
|
|
59
|
+
period: config.period,
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** Generate a set of single-use recovery codes. */
|
|
64
|
+
generateRecoveryCodes(count?: number): string[] {
|
|
65
|
+
return generateRecoveryCodes(count ?? JinaManager.config.twoFactor.recoveryCodes)
|
|
66
|
+
},
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Manager & provider
|
|
2
|
+
export { default, default as JinaManager } from './jina_manager.ts'
|
|
3
|
+
export { default as JinaProvider } from './jina_provider.ts'
|
|
4
|
+
|
|
5
|
+
// Helper
|
|
6
|
+
export { jina } 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 { JinaError, MissingActionError, ValidationError } from './errors.ts'
|
|
35
|
+
|
|
36
|
+
// Types
|
|
37
|
+
export type {
|
|
38
|
+
Feature,
|
|
39
|
+
JinaActions,
|
|
40
|
+
JinaConfig,
|
|
41
|
+
JinaEvent,
|
|
42
|
+
RegistrationData,
|
|
43
|
+
RateLimitConfig,
|
|
44
|
+
} from './types.ts'
|
|
45
|
+
export { JinaEvents } from './types.ts'
|
|
46
|
+
|
|
47
|
+
// TOTP utilities
|
|
48
|
+
export {
|
|
49
|
+
generateSecret,
|
|
50
|
+
totpUri,
|
|
51
|
+
verifyTotp,
|
|
52
|
+
generateRecoveryCodes,
|
|
53
|
+
base32Encode,
|
|
54
|
+
base32Decode,
|
|
55
|
+
} from './totp.ts'
|
|
56
|
+
|
|
57
|
+
// Token utilities
|
|
58
|
+
export { createSignedToken, verifySignedToken } from './tokens.ts'
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { inject, Configuration, ConfigurationError } from '@stravigor/kernel'
|
|
2
|
+
import { Router, compose, rateLimit, auth, guest, session } from '@stravigor/http'
|
|
3
|
+
import type { Handler, Middleware } from '@stravigor/http'
|
|
4
|
+
import { MissingActionError } from './errors.ts'
|
|
5
|
+
import type { JinaActions, JinaConfig, Feature } from './types.ts'
|
|
6
|
+
import { registerHandler } from './handlers/register.ts'
|
|
7
|
+
import { loginHandler } from './handlers/login.ts'
|
|
8
|
+
import { logoutHandler } from './handlers/logout.ts'
|
|
9
|
+
import { forgotPasswordHandler } from './handlers/forgot_password.ts'
|
|
10
|
+
import { resetPasswordHandler } from './handlers/reset_password.ts'
|
|
11
|
+
import { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
|
|
12
|
+
import {
|
|
13
|
+
enableTwoFactorHandler,
|
|
14
|
+
confirmTwoFactorHandler,
|
|
15
|
+
disableTwoFactorHandler,
|
|
16
|
+
twoFactorChallengeHandler,
|
|
17
|
+
} from './handlers/two_factor.ts'
|
|
18
|
+
import { confirmPasswordHandler } from './handlers/confirm_password.ts'
|
|
19
|
+
import { updatePasswordHandler } from './handlers/update_password.ts'
|
|
20
|
+
import { updateProfileHandler } from './handlers/update_profile.ts'
|
|
21
|
+
import { confirmed } from './middleware/confirmed.ts'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Default config
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const DEFAULTS: JinaConfig = {
|
|
28
|
+
features: ['registration', 'login', 'logout', 'password-reset'],
|
|
29
|
+
prefix: '',
|
|
30
|
+
mode: 'session',
|
|
31
|
+
rateLimit: {
|
|
32
|
+
login: { max: 5, window: 60 },
|
|
33
|
+
register: { max: 3, window: 60 },
|
|
34
|
+
forgotPassword: { max: 3, window: 60 },
|
|
35
|
+
verifyEmail: { max: 3, window: 60 },
|
|
36
|
+
twoFactor: { max: 5, window: 60 },
|
|
37
|
+
},
|
|
38
|
+
passwords: { expiration: 60 },
|
|
39
|
+
verification: { expiration: 60 },
|
|
40
|
+
confirmation: { timeout: 10_800 },
|
|
41
|
+
twoFactor: { issuer: 'Strav', digits: 6, period: 30, recoveryCodes: 8 },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Manager
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** Wrap a handler with middleware via compose. */
|
|
49
|
+
function withMiddleware(mw: Middleware[], handler: Handler): Handler {
|
|
50
|
+
return mw.length > 0 ? compose(mw, handler) : handler
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@inject
|
|
54
|
+
export default class JinaManager {
|
|
55
|
+
private static _config: JinaConfig
|
|
56
|
+
private static _actions: JinaActions
|
|
57
|
+
|
|
58
|
+
constructor(config: Configuration) {
|
|
59
|
+
const raw = config.get('jina', {}) as Partial<JinaConfig>
|
|
60
|
+
JinaManager._config = { ...DEFAULTS, ...raw } as JinaConfig
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Accessors ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
static get config(): JinaConfig {
|
|
66
|
+
if (!JinaManager._config) {
|
|
67
|
+
throw new ConfigurationError(
|
|
68
|
+
'JinaManager not configured. Resolve it through the container first.'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return JinaManager._config
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static get actions(): JinaActions {
|
|
75
|
+
if (!JinaManager._actions) {
|
|
76
|
+
throw new ConfigurationError('Jina actions not set. Pass actions to JinaProvider.')
|
|
77
|
+
}
|
|
78
|
+
return JinaManager._actions
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Set the user-defined actions contract. */
|
|
82
|
+
static useActions(actions: JinaActions): void {
|
|
83
|
+
JinaManager._actions = actions
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Check whether a feature is enabled. */
|
|
87
|
+
static hasFeature(feature: Feature): boolean {
|
|
88
|
+
return JinaManager._config.features.includes(feature)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Verify that all required actions are provided for enabled features. */
|
|
94
|
+
static validateActions(): void {
|
|
95
|
+
const a = JinaManager._actions
|
|
96
|
+
const has = (f: Feature) => JinaManager.hasFeature(f)
|
|
97
|
+
|
|
98
|
+
if (has('email-verification')) {
|
|
99
|
+
if (!a.isEmailVerified) throw new MissingActionError('isEmailVerified', 'email-verification')
|
|
100
|
+
if (!a.markEmailVerified)
|
|
101
|
+
throw new MissingActionError('markEmailVerified', 'email-verification')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (has('two-factor')) {
|
|
105
|
+
if (!a.twoFactorSecretOf) throw new MissingActionError('twoFactorSecretOf', 'two-factor')
|
|
106
|
+
if (!a.setTwoFactorSecret) throw new MissingActionError('setTwoFactorSecret', 'two-factor')
|
|
107
|
+
if (!a.recoveryCodesOf) throw new MissingActionError('recoveryCodesOf', 'two-factor')
|
|
108
|
+
if (!a.setRecoveryCodes) throw new MissingActionError('setRecoveryCodes', 'two-factor')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (has('update-profile')) {
|
|
112
|
+
if (!a.updateProfile) throw new MissingActionError('updateProfile', 'update-profile')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Route registration ───────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** Build a rate limit middleware from a config key. */
|
|
119
|
+
private static rl(key: keyof JinaConfig['rateLimit']): Middleware {
|
|
120
|
+
const cfg = JinaManager._config.rateLimit[key]
|
|
121
|
+
return rateLimit({ max: cfg.max, window: cfg.window * 1000 })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register all Jina routes on the given router.
|
|
126
|
+
*
|
|
127
|
+
* @param router - The router instance.
|
|
128
|
+
* @param options - `only` or `except` to selectively register routes.
|
|
129
|
+
*/
|
|
130
|
+
static routes(router: Router, options?: { only?: Feature[]; except?: Feature[] }): void {
|
|
131
|
+
const enabled = (f: Feature): boolean => {
|
|
132
|
+
if (!JinaManager.hasFeature(f)) return false
|
|
133
|
+
if (options?.only) return options.only.includes(f)
|
|
134
|
+
if (options?.except) return !options.except.includes(f)
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const prefix = JinaManager._config.prefix
|
|
139
|
+
|
|
140
|
+
const middleware = JinaManager._config.mode === 'session' ? [session()] : []
|
|
141
|
+
|
|
142
|
+
router.group({ prefix, middleware }, r => {
|
|
143
|
+
if (enabled('registration')) {
|
|
144
|
+
r.post('/register', withMiddleware([guest(), JinaManager.rl('register')], registerHandler))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (enabled('login')) {
|
|
148
|
+
r.post('/login', withMiddleware([guest(), JinaManager.rl('login')], loginHandler))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (enabled('logout')) {
|
|
152
|
+
r.post('/logout', withMiddleware([auth()], logoutHandler))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (enabled('password-reset')) {
|
|
156
|
+
r.post(
|
|
157
|
+
'/forgot-password',
|
|
158
|
+
withMiddleware([guest(), JinaManager.rl('forgotPassword')], forgotPasswordHandler)
|
|
159
|
+
)
|
|
160
|
+
r.post('/reset-password', withMiddleware([guest()], resetPasswordHandler))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (enabled('email-verification')) {
|
|
164
|
+
r.post(
|
|
165
|
+
'/email/send',
|
|
166
|
+
withMiddleware([auth(), JinaManager.rl('verifyEmail')], sendVerificationHandler)
|
|
167
|
+
)
|
|
168
|
+
r.get('/email/verify/:token', verifyEmailHandler)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (enabled('two-factor')) {
|
|
172
|
+
r.post('/two-factor/enable', withMiddleware([auth(), confirmed()], enableTwoFactorHandler))
|
|
173
|
+
r.post('/two-factor/confirm', withMiddleware([auth()], confirmTwoFactorHandler))
|
|
174
|
+
r.delete('/two-factor', withMiddleware([auth(), confirmed()], disableTwoFactorHandler))
|
|
175
|
+
r.post(
|
|
176
|
+
'/two-factor/challenge',
|
|
177
|
+
withMiddleware([JinaManager.rl('twoFactor')], twoFactorChallengeHandler)
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (enabled('password-confirmation')) {
|
|
182
|
+
r.post('/confirm-password', withMiddleware([auth()], confirmPasswordHandler))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (enabled('update-password')) {
|
|
186
|
+
r.put('/password', withMiddleware([auth()], updatePasswordHandler))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (enabled('update-profile')) {
|
|
190
|
+
r.put('/profile', withMiddleware([auth()], updateProfileHandler))
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Clear all state. For testing. */
|
|
196
|
+
static reset(): void {
|
|
197
|
+
JinaManager._config = undefined as any
|
|
198
|
+
JinaManager._actions = undefined as any
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/kernel'
|
|
2
|
+
import type { Application } from '@stravigor/kernel'
|
|
3
|
+
import { Router } from '@stravigor/http'
|
|
4
|
+
import JinaManager from './jina_manager.ts'
|
|
5
|
+
import type { JinaActions } from './types.ts'
|
|
6
|
+
|
|
7
|
+
export default class JinaProvider extends ServiceProvider {
|
|
8
|
+
readonly name = 'jina'
|
|
9
|
+
override readonly dependencies = ['auth', 'session', 'encryption', 'mail']
|
|
10
|
+
|
|
11
|
+
constructor(private actions: JinaActions) {
|
|
12
|
+
super()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override register(app: Application): void {
|
|
16
|
+
app.singleton(JinaManager)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override boot(app: Application): void {
|
|
20
|
+
app.resolve(JinaManager)
|
|
21
|
+
JinaManager.useActions(this.actions)
|
|
22
|
+
JinaManager.validateActions()
|
|
23
|
+
JinaManager.routes(app.resolve(Router))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Session, Middleware } from '@stravigor/http'
|
|
2
|
+
import JinaManager from '../jina_manager.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Require the user to have confirmed their password recently.
|
|
6
|
+
* Returns 423 (Locked) if the confirmation has expired.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* router.delete('/account', auth(), confirmed(), deleteAccountHandler)
|
|
10
|
+
*/
|
|
11
|
+
export function confirmed(): Middleware {
|
|
12
|
+
return (ctx, next) => {
|
|
13
|
+
const session = ctx.get<Session>('session')
|
|
14
|
+
const confirmedAt = session.get<number>('_jina_confirmed_at')
|
|
15
|
+
|
|
16
|
+
if (!confirmedAt) {
|
|
17
|
+
return ctx.json({ message: 'Password confirmation required.' }, 423)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const timeout = JinaManager.config.confirmation.timeout * 1000
|
|
21
|
+
if (Date.now() - confirmedAt > timeout) {
|
|
22
|
+
return ctx.json({ message: 'Password confirmation has expired.' }, 423)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return next()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Session, Middleware } from '@stravigor/http'
|
|
2
|
+
import JinaManager from '../jina_manager.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Require a completed two-factor challenge for the current session.
|
|
6
|
+
* Returns 403 if the user has 2FA enabled but hasn't completed the challenge.
|
|
7
|
+
*
|
|
8
|
+
* Useful for protecting sensitive routes beyond the initial login.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* router.post('/transfer', auth(), twoFactorChallenge(), transferHandler)
|
|
12
|
+
*/
|
|
13
|
+
export function twoFactorChallenge(): Middleware {
|
|
14
|
+
return (ctx, next) => {
|
|
15
|
+
const user = ctx.get('user')
|
|
16
|
+
const actions = JinaManager.actions
|
|
17
|
+
|
|
18
|
+
// If 2FA is not enabled for this user, let them through
|
|
19
|
+
if (!actions.twoFactorSecretOf) return next()
|
|
20
|
+
const secret = actions.twoFactorSecretOf(user)
|
|
21
|
+
if (!secret) return next()
|
|
22
|
+
|
|
23
|
+
// Check if there's still a pending 2FA challenge in the session
|
|
24
|
+
const session = ctx.get<Session>('session')
|
|
25
|
+
if (session.has('_jina_2fa_email')) {
|
|
26
|
+
return ctx.json({ message: 'Two-factor authentication required.' }, 403)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return next()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Middleware } from '@stravigor/http'
|
|
2
|
+
import JinaManager from '../jina_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 = JinaManager.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/kernel'
|
|
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
|
+
}
|