@owlmeans/auth-email 0.0.0-stage → 0.1.19

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,117 @@
1
+ import { contract, openProtocol, protocol, typed } from '@owlmeans/entrypoint'
2
+ import type { EntrypointOptions } from '@owlmeans/entrypoint'
3
+ import { backend, frontend, route, RouteMethod } from '@owlmeans/route'
4
+ import { GUARD_ED25519 } from '@owlmeans/auth-common'
5
+ import {
6
+ EMAIL_AUTH_API_PATH, EMAIL_AUTH_BASE, EMAIL_AUTH_OFFER, EMAIL_AUTH_OFFER_PATH, EMAIL_AUTH_OPTIONS,
7
+ EMAIL_AUTH_PASSWORD, EMAIL_AUTH_PASSWORD_DECLINE, EMAIL_AUTH_PASSWORD_PIN, EMAIL_AUTH_PASSWORD_REMOVE,
8
+ EMAIL_AUTH_PASSWORD_SET, EMAIL_AUTH_PASSWORD_STATUS, EMAIL_AUTH_PIN, EMAIL_AUTH_SIGN_IN, EMAIL_AUTH_TECH,
9
+ EMAIL_AUTH_TECH_GATE, EMAIL_AUTH_TECH_PIN, EMAIL_AUTH_TECH_WAIVER,
10
+ } from './consts.js'
11
+ import {
12
+ EmailEmptyRequestSchema, EmailPasswordPinRequestSchema, EmailPasswordRemoveRequestSchema,
13
+ EmailPasswordSetRequestSchema, EmailPinRequestSchema, EmailSignInRequestSchema, EmailTechPinRequestSchema,
14
+ } from './schemas.js'
15
+ import type {
16
+ EmailAuthEntrypointOptions, EmailAuthEntrypoints, EmailAuthOptionsView, EmailChallengeView, EmailEmptyRequest,
17
+ EmailPasswordPinRequest, EmailPasswordRemoveRequest, EmailPasswordSetRequest, EmailPasswordStatusView,
18
+ EmailPinRequest, EmailSignInRequest, EmailSignInView, EmailTechPinRequest, EmailTechPinView, EmailTechWaiverView,
19
+ } from './types.js'
20
+
21
+ /**
22
+ * The e-mail sign-in surface:
23
+ *
24
+ * - the guest base (`opts.path`, default `/auth/email`, under `opts.parent`) carries NO guards —
25
+ * `options` GET `/options`, `pin` POST `/pin`, `signIn` POST `/sign-in`;
26
+ * - `password` (`/password`, under the guest base or `opts.passwordParent`) acts on the caller's own
27
+ * account, so it needs the application's session: `opts.guards` (+ `opts.gate`) or a guarded
28
+ * `opts.passwordParent` — neither is a declaration error, not a runtime one. `status` GET `/`,
29
+ * `pin` POST `/pin`, `set` POST `/set`, `remove` POST `/remove`, `decline` POST `/decline`;
30
+ * - `tech` (`/tech`, developer tooling) defaults to `GUARD_ED25519` + the `EMAIL_AUTH_TECH_GATE`
31
+ * gate: `pin` POST `/pin`, `waiver` POST `/waiver`;
32
+ * - `offer`, the top-level sticky frontend screen of the post-sign-in password offer.
33
+ */
34
+ export const makeEmailAuthProtocols = (opts: EmailAuthEntrypointOptions = {}): EmailAuthEntrypoints => {
35
+ if (opts.passwordParent == null && opts.guards == null) {
36
+ throw new SyntaxError('auth-email: the password routes need either a passwordParent or explicit guards')
37
+ }
38
+
39
+ const get = (alias: string, path: string, parent: EmailAuthEntrypoints['base']) =>
40
+ route(alias, path, backend({ parent, method: RouteMethod.GET }))
41
+ const post = (alias: string, path: string, parent: EmailAuthEntrypoints['base']) =>
42
+ route(alias, path, backend({ parent, method: RouteMethod.POST }))
43
+
44
+ const base = openProtocol(route(EMAIL_AUTH_BASE, opts.path ?? EMAIL_AUTH_API_PATH, backend({ parent: opts.parent })))
45
+
46
+ const passwordOptions: EntrypointOptions = {
47
+ ...(opts.guards != null ? { guards: opts.guards } : {}),
48
+ ...(opts.gate != null ? { gate: opts.gate } : {}),
49
+ }
50
+ const passwordBase = openProtocol(
51
+ route(EMAIL_AUTH_PASSWORD, '/password', backend({ parent: opts.passwordParent ?? base })), passwordOptions
52
+ )
53
+
54
+ const techBase = openProtocol(route(EMAIL_AUTH_TECH, '/tech', backend({ parent: base })), {
55
+ guards: opts.techGuards ?? [GUARD_ED25519],
56
+ gate: opts.techGate ?? { alias: EMAIL_AUTH_TECH_GATE },
57
+ })
58
+
59
+ return {
60
+ base,
61
+ options: protocol(get(EMAIL_AUTH_OPTIONS, '/options', base), contract(typed<EmailAuthOptionsView>())),
62
+ pin: protocol(
63
+ post(EMAIL_AUTH_PIN, '/pin', base),
64
+ contract.request({ body: typed<EmailPinRequest>(EmailPinRequestSchema) }, typed<EmailChallengeView>())
65
+ ),
66
+ signIn: protocol(
67
+ post(EMAIL_AUTH_SIGN_IN, '/sign-in', base),
68
+ contract.request({ body: typed<EmailSignInRequest>(EmailSignInRequestSchema) }, typed<EmailSignInView>())
69
+ ),
70
+ password: {
71
+ base: passwordBase,
72
+ status: protocol(
73
+ get(EMAIL_AUTH_PASSWORD_STATUS, '/', passwordBase), contract(typed<EmailPasswordStatusView>())
74
+ ),
75
+ pin: protocol(
76
+ post(EMAIL_AUTH_PASSWORD_PIN, '/pin', passwordBase),
77
+ contract.request(
78
+ { body: typed<EmailPasswordPinRequest>(EmailPasswordPinRequestSchema) }, typed<EmailChallengeView>()
79
+ )
80
+ ),
81
+ set: protocol(
82
+ post(EMAIL_AUTH_PASSWORD_SET, '/set', passwordBase),
83
+ contract.request(
84
+ { body: typed<EmailPasswordSetRequest>(EmailPasswordSetRequestSchema) }, typed<EmailPasswordStatusView>()
85
+ )
86
+ ),
87
+ remove: protocol(
88
+ post(EMAIL_AUTH_PASSWORD_REMOVE, '/remove', passwordBase),
89
+ contract.request(
90
+ { body: typed<EmailPasswordRemoveRequest>(EmailPasswordRemoveRequestSchema) },
91
+ typed<EmailPasswordStatusView>()
92
+ )
93
+ ),
94
+ decline: protocol(
95
+ post(EMAIL_AUTH_PASSWORD_DECLINE, '/decline', passwordBase),
96
+ contract.request({ body: typed<EmailEmptyRequest>(EmailEmptyRequestSchema) }, typed<EmailPasswordStatusView>())
97
+ ),
98
+ },
99
+ tech: {
100
+ base: techBase,
101
+ pin: protocol(
102
+ post(EMAIL_AUTH_TECH_PIN, '/pin', techBase),
103
+ contract.request({ body: typed<EmailTechPinRequest>(EmailTechPinRequestSchema) }, typed<EmailTechPinView>())
104
+ ),
105
+ waiver: protocol(
106
+ post(EMAIL_AUTH_TECH_WAIVER, '/waiver', techBase),
107
+ contract.request({ body: typed<EmailEmptyRequest>(EmailEmptyRequestSchema) }, typed<EmailTechWaiverView>())
108
+ ),
109
+ },
110
+ // A top-level frontend screen, `sticky` so the router keeps it regardless of `cfg.service`
111
+ // filtering — the same shape as marketing consent's screen.
112
+ offer: openProtocol(
113
+ route(EMAIL_AUTH_OFFER, opts.screen?.path ?? EMAIL_AUTH_OFFER_PATH, frontend({ parent: opts.screen?.parent })),
114
+ { sticky: true }
115
+ ),
116
+ }
117
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { ResilientError } from '@owlmeans/error'
2
+
3
+ /**
4
+ * The e-mail sign-in error family, shared by the server that throws and the client that reads it
5
+ * back (each class is registered, so it survives the wire). A failed PIN or password check is
6
+ * `AuthenFailed` from `@owlmeans/auth`, never one of these: no answer here may tell an unknown
7
+ * address from a wrong credential.
8
+ */
9
+ export class EmailAuthError extends ResilientError {
10
+ public static override typeName: string = 'EmailAuthError'
11
+
12
+ constructor(message: string = 'error') {
13
+ super(EmailAuthError.typeName, `auth-email:${message}`)
14
+ }
15
+ }
16
+
17
+ /** A password sign-in or a password route on a deployment that does not offer passwords. */
18
+ export class EmailPasswordDisabled extends EmailAuthError {
19
+ public static override typeName: string = `${EmailAuthError.typeName}PasswordDisabled`
20
+ public static httpStatus = 404
21
+
22
+ constructor(message: string = 'password') {
23
+ super(`disabled:${message}`)
24
+ this.type = EmailPasswordDisabled.typeName
25
+ }
26
+ }
27
+
28
+ /** A new password the policy refuses (length bounds); the message names the rule. */
29
+ export class EmailPasswordWeak extends EmailAuthError {
30
+ public static override typeName: string = `${EmailAuthError.typeName}PasswordWeak`
31
+ public static httpStatus = 422
32
+
33
+ constructor(message: string = 'length') {
34
+ super(`weak:${message}`)
35
+ this.type = EmailPasswordWeak.typeName
36
+ }
37
+ }
38
+
39
+ /** The one-use offer ticket is spent, expired or another account's — confirm with a PIN instead. */
40
+ export class EmailOfferExpired extends EmailAuthError {
41
+ public static override typeName: string = `${EmailAuthError.typeName}OfferExpired`
42
+ public static httpStatus = 409
43
+
44
+ constructor(message: string = 'offer') {
45
+ super(`expired:${message}`)
46
+ this.type = EmailOfferExpired.typeName
47
+ }
48
+ }
49
+
50
+ /** A developer (tech) route on a deployment where they are off: answers as if it did not exist. */
51
+ export class EmailTechDisabled extends EmailAuthError {
52
+ public static override typeName: string = `${EmailAuthError.typeName}TechDisabled`
53
+ public static httpStatus = 404
54
+
55
+ constructor(message: string = 'tech') {
56
+ super(`disabled:${message}`)
57
+ this.type = EmailTechDisabled.typeName
58
+ }
59
+ }
60
+
61
+ /** A store, mailer or captcha authority could not be consulted, so the request fails closed. */
62
+ export class EmailAuthUnavailable extends EmailAuthError {
63
+ public static override typeName: string = `${EmailAuthError.typeName}Unavailable`
64
+ public static httpStatus = 503
65
+ public static allowServerErrorStatus = true
66
+
67
+ constructor(message: string = 'unavailable') {
68
+ super(`unavailable:${message}`)
69
+ this.type = EmailAuthUnavailable.typeName
70
+ }
71
+ }
72
+
73
+ ResilientError.registerErrorClass(EmailAuthError)
74
+ ResilientError.registerErrorClass(EmailPasswordDisabled)
75
+ ResilientError.registerErrorClass(EmailPasswordWeak)
76
+ ResilientError.registerErrorClass(EmailOfferExpired)
77
+ ResilientError.registerErrorClass(EmailTechDisabled)
78
+ ResilientError.registerErrorClass(EmailAuthUnavailable)
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './schemas.js'
4
+ export * from './errors.js'
5
+ export * from './entrypoints.js'
package/src/schemas.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { JSONSchemaType } from 'ajv'
2
+ import {
3
+ EMAIL_ADDRESS_MAX_LENGTH, EMAIL_DEVICE_ID_PATTERN, EMAIL_LOCALE_MAX_LENGTH, EMAIL_PASSWORD_WIRE_MAX_LENGTH,
4
+ EMAIL_PIN_PATTERN, EMAIL_TOKEN_MAX_LENGTH, EmailPasswordPurpose,
5
+ } from './consts.js'
6
+ import type {
7
+ EmailEmptyRequest, EmailPasswordPinRequest, EmailPasswordRemoveRequest, EmailPasswordSetRequest,
8
+ EmailPinRequest, EmailSignInRequest, EmailTechPinRequest,
9
+ } from './types.js'
10
+
11
+ // Every request schema refuses unknown fields; no body carries an organization or a source.
12
+ // The address is lowercased and trimmed by the server; the password policy is checked there too.
13
+
14
+ const email = { type: 'string', format: 'email', minLength: 3, maxLength: EMAIL_ADDRESS_MAX_LENGTH } as const
15
+ const device = { type: 'string', pattern: EMAIL_DEVICE_ID_PATTERN } as const
16
+ const pin = { type: 'string', pattern: EMAIL_PIN_PATTERN } as const
17
+ const token = { type: 'string', minLength: 1, maxLength: EMAIL_TOKEN_MAX_LENGTH } as const
18
+ const password = { type: 'string', minLength: 1, maxLength: EMAIL_PASSWORD_WIRE_MAX_LENGTH } as const
19
+ const locale = { type: 'string', minLength: 1, maxLength: EMAIL_LOCALE_MAX_LENGTH } as const
20
+
21
+ export const EmailPinRequestSchema: JSONSchemaType<EmailPinRequest> = {
22
+ type: 'object',
23
+ properties: {
24
+ email,
25
+ locale: { ...locale, nullable: true },
26
+ captcha: { ...token, nullable: true },
27
+ },
28
+ required: ['email'],
29
+ additionalProperties: false,
30
+ }
31
+
32
+ /** One schema for both modes; the server enforces exactly one of PIN (`challenge` + `pin`) or `password`. */
33
+ export const EmailSignInRequestSchema: JSONSchemaType<EmailSignInRequest> = {
34
+ type: 'object',
35
+ properties: {
36
+ email,
37
+ device,
38
+ challenge: { ...token, nullable: true },
39
+ pin: { ...pin, nullable: true },
40
+ password: { ...password, nullable: true },
41
+ captcha: { ...token, nullable: true },
42
+ locale: { ...locale, nullable: true },
43
+ },
44
+ required: ['email', 'device'],
45
+ additionalProperties: false,
46
+ }
47
+
48
+ export const EmailPasswordPinRequestSchema: JSONSchemaType<EmailPasswordPinRequest> = {
49
+ type: 'object',
50
+ properties: {
51
+ purpose: { type: 'string', enum: Object.values(EmailPasswordPurpose) },
52
+ locale: { ...locale, nullable: true },
53
+ },
54
+ required: ['purpose'],
55
+ additionalProperties: false,
56
+ }
57
+
58
+ export const EmailPasswordSetRequestSchema: JSONSchemaType<EmailPasswordSetRequest> = {
59
+ type: 'object',
60
+ properties: {
61
+ password,
62
+ offer: { ...token, nullable: true },
63
+ challenge: { ...token, nullable: true },
64
+ pin: { ...pin, nullable: true },
65
+ device: { ...device, nullable: true },
66
+ },
67
+ required: ['password'],
68
+ additionalProperties: false,
69
+ }
70
+
71
+ export const EmailPasswordRemoveRequestSchema: JSONSchemaType<EmailPasswordRemoveRequest> = {
72
+ type: 'object',
73
+ properties: {
74
+ challenge: token,
75
+ pin,
76
+ device: { ...device, nullable: true },
77
+ },
78
+ required: ['challenge', 'pin'],
79
+ additionalProperties: false,
80
+ }
81
+
82
+ export const EmailTechPinRequestSchema: JSONSchemaType<EmailTechPinRequest> = {
83
+ type: 'object',
84
+ properties: { challenge: token, pin },
85
+ required: ['challenge', 'pin'],
86
+ additionalProperties: false,
87
+ }
88
+
89
+ /** The body of `password/decline` and `tech/waiver`: an empty object. */
90
+ export const EmailEmptyRequestSchema = {
91
+ type: 'object',
92
+ properties: {},
93
+ required: [],
94
+ additionalProperties: false,
95
+ } as unknown as JSONSchemaType<EmailEmptyRequest>
package/src/types.ts ADDED
@@ -0,0 +1,179 @@
1
+ import type { EntrypointProtocol, OpenRequest, OpenValue } from '@owlmeans/entrypoint'
2
+ import type { RouteParent } from '@owlmeans/route'
3
+ import type { EMAIL_AUTH_LOCALES, EMAIL_CAPTCHA_PROVIDER, EmailPasswordPurpose, EmailSignInReason } from './consts.js'
4
+
5
+ /** One of the languages the sign-in mails are written in. */
6
+ export type EmailAuthLocale = typeof EMAIL_AUTH_LOCALES[number]
7
+
8
+ // --- Views (answers) ---------------------------------------------------------------------------
9
+
10
+ /** The captcha a client must solve before send-PIN and password sign-in. */
11
+ export interface EmailAuthCaptchaView {
12
+ provider: typeof EMAIL_CAPTCHA_PROVIDER
13
+ siteKey: string
14
+ }
15
+
16
+ /** `GET options` — what this deployment offers; asks for no address. */
17
+ export interface EmailAuthOptionsView {
18
+ /** Password sign-in and the password routes are served. */
19
+ password: boolean
20
+ /** `null` when captcha is off (or a valid developer waiver came with the request). */
21
+ captcha: EmailAuthCaptchaView | null
22
+ pinLength: number
23
+ }
24
+
25
+ /** A PIN was mailed: the signed challenge to send back with it. */
26
+ export interface EmailChallengeView {
27
+ challenge: string
28
+ }
29
+
30
+ /**
31
+ * `POST sign-in`. A signed-in answer carries `token` (and, after a PIN sign-in of a person without
32
+ * a password who has not declined one, the one-use `offer` ticket); a password sign-in on an
33
+ * unknown device carries `challenge` + `reason: device` instead — a PIN was mailed and the password
34
+ * was not checked.
35
+ */
36
+ export interface EmailSignInView {
37
+ token?: string
38
+ offer?: string
39
+ challenge?: string
40
+ reason?: EmailSignInReason
41
+ }
42
+
43
+ /** The signed-in account's password state. */
44
+ export interface EmailPasswordStatusView {
45
+ /** Passwords are enabled on this deployment. */
46
+ enabled: boolean
47
+ set: boolean
48
+ /** The person declined the post-sign-in offer (or removed their password). */
49
+ declined: boolean
50
+ /** ISO time of the last set/change/remove. */
51
+ updatedAt?: string
52
+ }
53
+
54
+ export interface EmailTechPinView {
55
+ ok: boolean
56
+ }
57
+
58
+ /** A developer waiver: send `waiver` in `EMAIL_WAIVER_HEADER` until `expiresAt` (ISO time). */
59
+ export interface EmailTechWaiverView {
60
+ waiver: string
61
+ expiresAt: string
62
+ }
63
+
64
+ // --- Requests (bodies) -------------------------------------------------------------------------
65
+
66
+ /** A body with no fields (decline, waiver). */
67
+ export type EmailEmptyRequest = Record<string, never>
68
+
69
+ /** `POST pin` — mail a sign-in PIN; the same answer for a known and a new address. */
70
+ export interface EmailPinRequest {
71
+ email: string
72
+ locale?: string
73
+ captcha?: string
74
+ }
75
+
76
+ /**
77
+ * `POST sign-in` — ONE body for both modes: PIN mode sends `challenge` + `pin`, password mode
78
+ * sends `password` (+ `captcha`). The server accepts exactly one mode.
79
+ */
80
+ export interface EmailSignInRequest {
81
+ email: string
82
+ /** The browser's device id (`EMAIL_DEVICE_ID_PATTERN`). */
83
+ device: string
84
+ challenge?: string
85
+ pin?: string
86
+ password?: string
87
+ captcha?: string
88
+ locale?: string
89
+ }
90
+
91
+ /** `POST password/pin` — mail a confirmation PIN to the account's own address. */
92
+ export interface EmailPasswordPinRequest {
93
+ purpose: EmailPasswordPurpose
94
+ locale?: string
95
+ }
96
+
97
+ /**
98
+ * `POST password/set` — confirmed either by the `offer` ticket of a fresh PIN sign-in or by a
99
+ * `challenge` + `pin` from `password/pin`. `device` keeps the current browser remembered while the
100
+ * account's other devices are forgotten.
101
+ */
102
+ export interface EmailPasswordSetRequest {
103
+ password: string
104
+ offer?: string
105
+ challenge?: string
106
+ pin?: string
107
+ device?: string
108
+ }
109
+
110
+ /** `POST password/remove` — confirmed by a `challenge` + `pin` from `password/pin`. */
111
+ export interface EmailPasswordRemoveRequest {
112
+ challenge: string
113
+ pin: string
114
+ device?: string
115
+ }
116
+
117
+ /** `POST tech/pin` — a developer replaces the PIN of a pending challenge. */
118
+ export interface EmailTechPinRequest {
119
+ challenge: string
120
+ pin: string
121
+ }
122
+
123
+ // --- Protocol tree -----------------------------------------------------------------------------
124
+
125
+ export interface EmailAuthGateOption {
126
+ alias: string
127
+ params?: string | readonly string[]
128
+ }
129
+
130
+ /**
131
+ * The guest routes (`options`, `pin`, `sign-in`) are never guarded by this factory. The password
132
+ * routes need the application's session: `guards` (+ `gate`), or a `passwordParent` whose guards
133
+ * they inherit — neither is a declaration error. The developer (tech) routes default to
134
+ * `GUARD_ED25519` + the `EMAIL_AUTH_TECH_GATE` gate.
135
+ */
136
+ export interface EmailAuthEntrypointOptions {
137
+ /** The guest base path; default `EMAIL_AUTH_API_PATH`. */
138
+ path?: string
139
+ /** The guest base's parent route. */
140
+ parent?: RouteParent
141
+ /** Guards of the password subtree. */
142
+ guards?: string | readonly string[]
143
+ /** Gate of the password subtree. */
144
+ gate?: EmailAuthGateOption
145
+ /** Mount the password subtree (`/password`) under this route instead of the guest base. */
146
+ passwordParent?: RouteParent
147
+ /** Guards of the tech subtree; default `[GUARD_ED25519]`. */
148
+ techGuards?: string | readonly string[]
149
+ /** Gate of the tech subtree; default `{ alias: EMAIL_AUTH_TECH_GATE }`. */
150
+ techGate?: EmailAuthGateOption
151
+ /** The offer screen; default path `EMAIL_AUTH_OFFER_PATH`, top-level. */
152
+ screen?: { path?: string, parent?: RouteParent }
153
+ }
154
+
155
+ export interface EmailPasswordEntrypoints {
156
+ base: EntrypointProtocol<OpenRequest, OpenValue>
157
+ status: EntrypointProtocol<{}, EmailPasswordStatusView>
158
+ pin: EntrypointProtocol<{ body: EmailPasswordPinRequest }, EmailChallengeView>
159
+ set: EntrypointProtocol<{ body: EmailPasswordSetRequest }, EmailPasswordStatusView>
160
+ remove: EntrypointProtocol<{ body: EmailPasswordRemoveRequest }, EmailPasswordStatusView>
161
+ decline: EntrypointProtocol<{ body: EmailEmptyRequest }, EmailPasswordStatusView>
162
+ }
163
+
164
+ export interface EmailTechEntrypoints {
165
+ base: EntrypointProtocol<OpenRequest, OpenValue>
166
+ pin: EntrypointProtocol<{ body: EmailTechPinRequest }, EmailTechPinView>
167
+ waiver: EntrypointProtocol<{ body: EmailEmptyRequest }, EmailTechWaiverView>
168
+ }
169
+
170
+ export interface EmailAuthEntrypoints {
171
+ base: EntrypointProtocol<OpenRequest, OpenValue>
172
+ options: EntrypointProtocol<{}, EmailAuthOptionsView>
173
+ pin: EntrypointProtocol<{ body: EmailPinRequest }, EmailChallengeView>
174
+ signIn: EntrypointProtocol<{ body: EmailSignInRequest }, EmailSignInView>
175
+ password: EmailPasswordEntrypoints
176
+ tech: EmailTechEntrypoints
177
+ /** The post-sign-in "set a password" screen — frontend, sticky. */
178
+ offer: EntrypointProtocol<OpenRequest, OpenValue>
179
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ResilientError } from '@owlmeans/error'
3
+ import * as email from '@owlmeans/auth-email'
4
+ import {
5
+ EmailAuthError, EmailAuthUnavailable, EmailOfferExpired, EmailPasswordDisabled, EmailPasswordWeak,
6
+ EmailTechDisabled,
7
+ } from '@owlmeans/auth-email'
8
+
9
+ /** Server, browser and stored records agree on these values; a change here breaks one of them. */
10
+ describe('@owlmeans/auth-email — constants', () => {
11
+ test('names the methods, token types, envelopes, header and captcha actions', () => {
12
+ expect(email.EMAIL_LOGIN_METHOD).toBe('email')
13
+ expect(email.EMAIL_PIN_AUTH_TYPE).toBe('email-pin')
14
+ expect(email.EMAIL_PASSWORD_AUTH_TYPE).toBe('email-password')
15
+ expect(email.EMAIL_CONFIRM_ENVELOPE).toBe('email-confirm')
16
+ expect(email.EMAIL_WAIVER_ENVELOPE).toBe('email-waiver')
17
+ expect(email.EMAIL_WAIVER_HEADER).toBe('x-owlmeans-email-waiver')
18
+ expect(email.EMAIL_CAPTCHA_ACTION_PIN).toBe('email_pin')
19
+ expect(email.EMAIL_CAPTCHA_ACTION_PASSWORD).toBe('email_password')
20
+ expect(email.EMAIL_CAPTCHA_PROVIDER).toBe('recaptcha-v3')
21
+ expect(email.EMAIL_IDENTITY_TYPE).toBe('email-otp')
22
+ })
23
+
24
+ test('pins the policy numbers and patterns', () => {
25
+ expect(email.EMAIL_PIN_LENGTH).toBe(6)
26
+ expect(email.EMAIL_PIN_PATTERN).toBe('^[0-9]{6}$')
27
+ expect(email.EMAIL_DEVICE_TTL_DAYS).toBe(30)
28
+ expect(email.EMAIL_DEVICE_LIMIT).toBe(10)
29
+ expect(email.EMAIL_DEVICE_ID_PATTERN).toBe('^[A-Za-z0-9_-]{22,64}$')
30
+ expect(email.EMAIL_OFFER_TTL_SECONDS).toBe(900)
31
+ expect(email.EMAIL_WAIVER_TTL_SECONDS).toBe(7200)
32
+ expect([email.EMAIL_PASSWORD_MIN_LENGTH, email.EMAIL_PASSWORD_MAX_LENGTH]).toEqual([8, 128])
33
+ expect([...email.EMAIL_AUTH_LOCALES]).toEqual(['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr'])
34
+ expect(Object.isFrozen(email.EMAIL_AUTH_LOCALES)).toBe(true)
35
+ })
36
+
37
+ test('pins the enums, service aliases and paths', () => {
38
+ expect(Object.values(email.EmailPasswordPurpose) as string[]).toEqual(['set', 'change', 'remove'])
39
+ expect(email.EmailSignInReason.Device as string).toBe('device')
40
+ expect(email.EMAIL_AUTH_SERVICE).toBe('auth-email')
41
+ expect(email.EMAIL_LOGIN_SERVICE).toBe('auth-email-client')
42
+ expect(email.EMAIL_AUTH_TECH_GATE).toBe('auth-email:tech-gate')
43
+ expect(email.EMAIL_AUTH_API_PATH).toBe('/auth/email')
44
+ expect(email.EMAIL_AUTH_OFFER_PATH).toBe('/login/password')
45
+ })
46
+ })
47
+
48
+ describe('@owlmeans/auth-email — errors', () => {
49
+ test('each class survives a marshal round trip with its type and status', () => {
50
+ const cases: Array<[ResilientError, Function, number]> = [
51
+ [new EmailPasswordDisabled(), EmailPasswordDisabled, 404],
52
+ [new EmailPasswordWeak('length'), EmailPasswordWeak, 422],
53
+ [new EmailOfferExpired(), EmailOfferExpired, 409],
54
+ [new EmailTechDisabled(), EmailTechDisabled, 404],
55
+ [new EmailAuthUnavailable('mail'), EmailAuthUnavailable, 503],
56
+ ]
57
+ for (const [error, Class, status] of cases) {
58
+ const restored = ResilientError.ensure(ResilientError.marshal(error))
59
+ expect(restored).toBeInstanceOf(Class)
60
+ expect(restored).toBeInstanceOf(EmailAuthError)
61
+ expect(restored.message).toBe(error.message)
62
+ expect((Class as unknown as { httpStatus: number }).httpStatus).toBe(status)
63
+ }
64
+ expect(new EmailPasswordWeak('length').message).toBe('auth-email:weak:length')
65
+ })
66
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { protocols } from '@owlmeans/entrypoint'
3
+ import type { EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
4
+ import { AppType } from '@owlmeans/context'
5
+ import { RouteMethod } from '@owlmeans/route'
6
+ import { GUARD_ED25519 } from '@owlmeans/auth-common'
7
+ import {
8
+ EMAIL_AUTH_BASE, EMAIL_AUTH_PASSWORD, EMAIL_AUTH_TECH, EMAIL_AUTH_TECH_GATE, makeEmailAuthProtocols,
9
+ } from '@owlmeans/auth-email'
10
+ import type { EmailAuthEntrypoints } from '@owlmeans/auth-email'
11
+
12
+ const GUARDED = { guards: 'auth', gate: { alias: 'app:gate', params: ['owner'] } }
13
+
14
+ const declarations = (tree: EmailAuthEntrypoints): EntrypointProtocolDeclaration[] => protocols(tree as never)
15
+
16
+ /** The full path of a declaration: its parents' segments, then its own. */
17
+ const pathOf = (tree: EmailAuthEntrypoints, declaration: EntrypointProtocolDeclaration): string => {
18
+ const byAlias = new Map(declarations(tree).map(entry => [entry.alias, entry]))
19
+ const segments: string[] = []
20
+ let current: EntrypointProtocolDeclaration | undefined = declaration
21
+ while (current != null) {
22
+ segments.unshift(current.route.route.path)
23
+ current = current.route.route.parent == null ? undefined : byAlias.get(current.route.route.parent)
24
+ }
25
+
26
+ return '/' + segments.map(segment => segment.replace(/^\/|\/$/g, '')).filter(Boolean).join('/')
27
+ }
28
+
29
+ describe('@owlmeans/auth-email — makeEmailAuthProtocols', () => {
30
+ test('builds every route with its alias, method and path', () => {
31
+ const tree = makeEmailAuthProtocols(GUARDED)
32
+ const routes = declarations(tree).filter(entry => entry !== tree.offer).map(entry => [
33
+ entry.alias, entry.route.route.method ?? RouteMethod.GET, pathOf(tree, entry),
34
+ ])
35
+
36
+ expect(routes).toEqual([
37
+ ['auth-email:base', RouteMethod.GET, '/auth/email'],
38
+ ['auth-email:options', RouteMethod.GET, '/auth/email/options'],
39
+ ['auth-email:pin', RouteMethod.POST, '/auth/email/pin'],
40
+ ['auth-email:sign-in', RouteMethod.POST, '/auth/email/sign-in'],
41
+ ['auth-email:password', RouteMethod.GET, '/auth/email/password'],
42
+ ['auth-email:password:status', RouteMethod.GET, '/auth/email/password'],
43
+ ['auth-email:password:pin', RouteMethod.POST, '/auth/email/password/pin'],
44
+ ['auth-email:password:set', RouteMethod.POST, '/auth/email/password/set'],
45
+ ['auth-email:password:remove', RouteMethod.POST, '/auth/email/password/remove'],
46
+ ['auth-email:password:decline', RouteMethod.POST, '/auth/email/password/decline'],
47
+ ['auth-email:tech', RouteMethod.GET, '/auth/email/tech'],
48
+ ['auth-email:tech:pin', RouteMethod.POST, '/auth/email/tech/pin'],
49
+ ['auth-email:tech:waiver', RouteMethod.POST, '/auth/email/tech/waiver'],
50
+ ])
51
+ expect(new Set(declarations(tree).map(entry => entry.alias)).size).toBe(14)
52
+ })
53
+
54
+ test('the guest routes carry no guards; password and tech carry theirs for their children', () => {
55
+ const tree = makeEmailAuthProtocols(GUARDED)
56
+
57
+ for (const guest of [tree.base, tree.options, tree.pin, tree.signIn]) {
58
+ expect(guest.guards).toEqual([])
59
+ expect(guest.gate).toBeUndefined()
60
+ }
61
+ expect(tree.password.base.guards).toEqual(['auth'])
62
+ expect(tree.password.base.gate).toEqual({ alias: 'app:gate', params: ['owner'] })
63
+ expect(tree.password.set.route.route.parent).toBe(EMAIL_AUTH_PASSWORD)
64
+ expect(tree.tech.base.guards).toEqual([GUARD_ED25519])
65
+ expect(tree.tech.base.gate).toEqual({ alias: EMAIL_AUTH_TECH_GATE, params: [] })
66
+ expect(tree.tech.waiver.route.route.parent).toBe(EMAIL_AUTH_TECH)
67
+ })
68
+
69
+ test('a passwordParent stands in for guards; path, parent and tech guards are overridable', () => {
70
+ const tree = makeEmailAuthProtocols({
71
+ path: '/email', parent: 'app:api', passwordParent: 'app:account',
72
+ techGuards: 'guard:dev', techGate: { alias: 'dev:gate' },
73
+ })
74
+
75
+ expect(tree.base.route.route.path).toBe('/email')
76
+ expect(tree.base.route.route.parent).toBe('app:api')
77
+ expect(tree.password.base.route.route.parent).toBe('app:account')
78
+ expect(tree.password.base.guards).toEqual([])
79
+ expect(tree.signIn.route.route.parent).toBe(EMAIL_AUTH_BASE)
80
+ expect(tree.tech.base.guards).toEqual(['guard:dev'])
81
+ expect(tree.tech.base.gate).toEqual({ alias: 'dev:gate', params: [] })
82
+ })
83
+
84
+ test('without guards or a passwordParent the declaration fails', () => {
85
+ expect(() => makeEmailAuthProtocols()).toThrow(SyntaxError)
86
+ expect(() => makeEmailAuthProtocols({ gate: { alias: 'app:gate' } })).toThrow(/passwordParent or explicit guards/)
87
+ })
88
+
89
+ test('the offer screen is a top-level sticky frontend route', () => {
90
+ const tree = makeEmailAuthProtocols(GUARDED)
91
+ const moved = makeEmailAuthProtocols({ ...GUARDED, screen: { path: '/welcome/password', parent: 'app:home' } })
92
+
93
+ expect(tree.offer.alias).toBe('auth-email:offer')
94
+ expect(tree.offer.sticky).toBe(true)
95
+ expect(tree.offer.route.route.type).toBe(AppType.Frontend)
96
+ expect(tree.offer.route.route.path).toBe('/login/password')
97
+ expect(tree.offer.route.route.parent).toBeUndefined()
98
+ expect(moved.offer.route.route.path).toBe('/welcome/password')
99
+ expect(moved.offer.route.route.parent).toBe('app:home')
100
+ })
101
+ })