@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.
- package/README.md +151 -2
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/auth-email/SKILL.md +107 -0
- package/build/consts.d.ts +78 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +85 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +16 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +58 -0
- package/build/entrypoints.js.map +1 -0
- package/build/errors.d.ts +43 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +66 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/schemas.d.ts +12 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +79 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +173 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +46 -4
- package/src/consts.ts +107 -0
- package/src/entrypoints.ts +117 -0
- package/src/errors.ts +78 -0
- package/src/index.ts +5 -0
- package/src/schemas.ts +95 -0
- package/src/types.ts +179 -0
- package/tests/consts.spec.ts +66 -0
- package/tests/entrypoints.spec.ts +101 -0
- package/tests/schemas.spec.ts +95 -0
- package/tsconfig.json +15 -0
|
@@ -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
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
|
+
})
|