@owlmeans/server-auth-otp 0.1.6

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,4 @@
1
+ export declare const SERVER_AUTH_OTP = "server-auth-otp";
2
+ /** The OTP auth plugin type key — passed as credential.type in init requests */
3
+ export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp';
4
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,oBAAoB,CAAA;AAEhD,gFAAgF;AAChF,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA"}
@@ -0,0 +1,4 @@
1
+ export const SERVER_AUTH_OTP = 'server-auth-otp';
2
+ /** The OTP auth plugin type key — passed as credential.type in init requests */
3
+ export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp';
4
+ //# sourceMappingURL=consts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAA;AAEhD,gFAAgF;AAChF,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA"}
@@ -0,0 +1,5 @@
1
+ export type * from './types.js';
2
+ export * from './consts.js';
3
+ export * from './service.js';
4
+ export * from './plugin.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './consts.js';
2
+ export * from './service.js';
3
+ export * from './plugin.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
@@ -0,0 +1,4 @@
1
+ import type { OtpConfig, OtpContext } from './types.js';
2
+ /** Register the email-OTP plugin into the server-auth plugin registry. Call once at context setup. */
3
+ export declare const appendOtpPlugin: <C extends OtpConfig, T extends OtpContext<C>>(context: T) => T;
4
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AA6DvD,sGAAsG;AACtG,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,SAAS,EAAE,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,KAAG,CAG1F,CAAA"}
@@ -0,0 +1,59 @@
1
+ import { ALL_SCOPES, AuthenFailed, AuthRole, AuthenticationType } from '@owlmeans/auth';
2
+ import { registerPlugin } from '@owlmeans/server-auth/manager/plugins';
3
+ import { OTP_AUTH_TYPE, OTP_SERVICE } from '@owlmeans/auth-otp';
4
+ import { AUTH_IDENTITY_LINKING } from '@owlmeans/server-auth-identity';
5
+ /** Factory that creates the email-OTP AuthPlugin bound to the given context. */
6
+ const otpPlugin = (context) => ({
7
+ type: OTP_AUTH_TYPE,
8
+ init: async (request) => {
9
+ if (request.userId == null || !request.userId.includes('@')) {
10
+ throw new AuthenFailed('otp:email');
11
+ }
12
+ const email = request.userId.trim().toLowerCase();
13
+ const otp = context.service(OTP_SERVICE);
14
+ await otp.issueChallenge(email);
15
+ // Store the email in the challenge so the envelope preserves it for authenticate.
16
+ return { challenge: email };
17
+ },
18
+ authenticate: async (credential) => {
19
+ // credential.challenge is the email (opened from the signed envelope in makeAuthModel).
20
+ const email = credential.challenge;
21
+ const code = credential.credential;
22
+ if (!email || !email.includes('@')) {
23
+ throw new AuthenFailed('otp:email');
24
+ }
25
+ if (!code) {
26
+ throw new AuthenFailed('otp:code');
27
+ }
28
+ const otp = context.service(OTP_SERVICE);
29
+ await otp.verifyChallenge(email, code);
30
+ const identityAlias = context.cfg.otp?.identityAlias ?? AUTH_IDENTITY_LINKING;
31
+ const identity = context.service(identityAlias);
32
+ const entityId = credential.entityId;
33
+ const details = {
34
+ type: OTP_AUTH_TYPE,
35
+ service: 'email',
36
+ clientId: entityId ?? 'default',
37
+ userId: email,
38
+ entityId,
39
+ };
40
+ let payload = await identity.getLinkedProfile(details);
41
+ if (payload == null) {
42
+ payload = await identity.linkProfile(details, { username: email });
43
+ }
44
+ // Set the resulting credential fields for the envelope.
45
+ credential.type = AuthenticationType.OneTimeToken;
46
+ credential.userId = payload.userId;
47
+ credential.profileId = payload.profileId;
48
+ credential.entityId = payload.entityId;
49
+ credential.role = payload.role ?? AuthRole.User;
50
+ credential.scopes = payload.scopes ?? [ALL_SCOPES];
51
+ return { token: '' };
52
+ },
53
+ });
54
+ /** Register the email-OTP plugin into the server-auth plugin registry. Call once at context setup. */
55
+ export const appendOtpPlugin = (context) => {
56
+ registerPlugin(OTP_AUTH_TYPE, otpPlugin);
57
+ return context;
58
+ };
59
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { OtpService } from '@owlmeans/auth-otp';
2
+ export declare const makeOtpService: (alias?: string) => OtpService;
3
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAgBpD,eAAO,MAAM,cAAc,GAAI,cAAmB,KAAG,UA+CpD,CAAA"}
@@ -0,0 +1,51 @@
1
+ import { appendContextual } from '@owlmeans/context';
2
+ import { AuthenFailed } from '@owlmeans/auth';
3
+ import { OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp';
4
+ import { MAILER_SERVICE } from '@owlmeans/mailer';
5
+ const codeKey = (email) => email.toLowerCase().replace(/[^a-z0-9]/g, '_');
6
+ export const makeOtpService = (alias = OTP_SERVICE) => {
7
+ const service = appendContextual(alias, {
8
+ issueChallenge: async (email) => {
9
+ const ctx = service.ctx;
10
+ const resourceAlias = ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE;
11
+ const mailerAlias = ctx.cfg.otp?.mailerAlias ?? MAILER_SERVICE;
12
+ const resource = ctx.resource(resourceAlias);
13
+ const mailer = ctx.service(mailerAlias);
14
+ const code = generateCode();
15
+ const id = codeKey(email);
16
+ // Upsert: delete previous code if any, then create fresh.
17
+ try {
18
+ await resource.delete(id);
19
+ }
20
+ catch { /* key may not exist */ }
21
+ await resource.create({ id, code }, { ttl: OTP_TTL_SECONDS });
22
+ await mailer.send({
23
+ to: email,
24
+ subject: 'Your login code',
25
+ text: `Your one-time login code is: ${code}\n\nIt expires in ${OTP_TTL_SECONDS / 60} minutes.`,
26
+ html: `<p>Your one-time login code is: <strong>${code}</strong></p><p>It expires in ${OTP_TTL_SECONDS / 60} minutes.</p>`,
27
+ });
28
+ },
29
+ verifyChallenge: async (email, code) => {
30
+ const ctx = service.ctx;
31
+ const resourceAlias = ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE;
32
+ const resource = ctx.resource(resourceAlias);
33
+ const id = codeKey(email);
34
+ const record = await resource.load(id);
35
+ if (record == null || record.code !== code.trim()) {
36
+ throw new AuthenFailed('otp:code');
37
+ }
38
+ // Consume: delete so the code cannot be reused.
39
+ try {
40
+ await resource.delete(id);
41
+ }
42
+ catch { /* already expired */ }
43
+ },
44
+ });
45
+ return service;
46
+ };
47
+ const generateCode = () => {
48
+ const digits = Math.floor(Math.random() * Math.pow(10, OTP_CODE_LENGTH));
49
+ return String(digits).padStart(OTP_CODE_LENGTH, '0');
50
+ };
51
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAA;AAIhG,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAQjD,MAAM,OAAO,GAAG,CAAC,KAAa,EAAU,EAAE,CACxC,KAAK,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,YAAY,EAAE,GAAG,CAAC,CAAA;AAEhD,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,KAAK,GAAG,WAAW,EAAc,EAAE;IAChE,MAAM,OAAO,GAAe,gBAAgB,CAAa,KAAK,EAAE;QAC9D,cAAc,EAAE,KAAK,EAAE,KAAa,EAAiB,EAAE;YACrD,MAAM,GAAG,GAAG,OAAO,CAAC,GAA4B,CAAA;YAChD,MAAM,aAAa,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,aAAa,IAAI,YAAY,CAAA;YAChE,MAAM,WAAW,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,WAAW,IAAI,cAAc,CAAA;YAC9D,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAA2B,aAAa,CAAC,CAAA;YACtE,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAgB,WAAW,CAAC,CAAA;YAEtD,MAAM,IAAI,GAAG,YAAY,EAAE,CAAA;YAC3B,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;YAEzB,0DAA0D;YAC1D,IAAI,CAAC;gBACH,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;YAC3B,CAAC;YAAC,MAAM,CAAC,CAAC,uBAAuB,CAAC,CAAC;YAEnC,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,eAAe,EAAE,CAAC,CAAA;YAE7D,MAAM,MAAM,CAAC,IAAI,CAAC;gBAChB,EAAE,EAAE,KAAK;gBACT,OAAO,EAAE,iBAAiB;gBAC1B,IAAI,EAAE,gCAAgC,IAAI,qBAAqB,eAAe,GAAG,EAAE,WAAW;gBAC9F,IAAI,EAAE,2CAA2C,IAAI,iCAAiC,eAAe,GAAG,EAAE,eAAe;aAC1H,CAAC,CAAA;QACJ,CAAC;QAED,eAAe,EAAE,KAAK,EAAE,KAAa,EAAE,IAAY,EAAiB,EAAE;YACpE,MAAM,GAAG,GAAG,OAAO,CAAC,GAA4B,CAAA;YAChD,MAAM,aAAa,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,aAAa,IAAI,YAAY,CAAA;YAChE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAA2B,aAAa,CAAC,CAAA;YAEtE,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;YACzB,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;YAEtC,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;gBAClD,MAAM,IAAI,YAAY,CAAC,UAAU,CAAC,CAAA;YACpC,CAAC;YAED,gDAAgD;YAChD,IAAI,CAAC;gBACH,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;YAC3B,CAAC;YAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC,CAAC;QACnC,CAAC;KACF,CAAC,CAAA;IAEF,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED,MAAM,YAAY,GAAG,GAAW,EAAE;IAChC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,eAAe,CAAC,CAAC,CAAA;IACxE,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,eAAe,EAAE,GAAG,CAAC,CAAA;AACtD,CAAC,CAAA"}
@@ -0,0 +1,14 @@
1
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context';
2
+ export interface OtpConfig extends ServerConfig {
3
+ otp?: {
4
+ /** Alias of the registered MailerService. Defaults to MAILER_SERVICE. */
5
+ mailerAlias?: string;
6
+ /** Alias of the Redis resource for code storage. Defaults to OTP_RESOURCE. */
7
+ resourceAlias?: string;
8
+ /** Alias of the IdentityLinkingService. Defaults to AUTH_IDENTITY_LINKING. */
9
+ identityAlias?: string;
10
+ };
11
+ }
12
+ export interface OtpContext<C extends OtpConfig = OtpConfig> extends ServerContext<C> {
13
+ }
14
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAE3E,MAAM,WAAW,SAAU,SAAQ,YAAY;IAC7C,GAAG,CAAC,EAAE;QACJ,yEAAyE;QACzE,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,8EAA8E;QAC9E,aAAa,CAAC,EAAE,MAAM,CAAA;QACtB,8EAA8E;QAC9E,aAAa,CAAC,EAAE,MAAM,CAAA;KACvB,CAAA;CACF;AAED,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,SAAS,GAAG,SAAS,CAAE,SAAQ,aAAa,CAAC,CAAC,CAAC;CAAG"}
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@owlmeans/server-auth-otp",
3
+ "version": "0.1.6",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc -b",
8
+ "dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
11
+ },
12
+ "main": "build/index.js",
13
+ "module": "build/index.js",
14
+ "types": "build/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./build/index.js",
18
+ "require": "./build/index.js",
19
+ "default": "./build/index.js",
20
+ "module": "./build/index.js",
21
+ "types": "./build/index.d.ts"
22
+ }
23
+ },
24
+ "dependencies": {
25
+ "@owlmeans/auth": "^0.1.6",
26
+ "@owlmeans/auth-otp": "^0.1.6",
27
+ "@owlmeans/basic-ids": "^0.1.6",
28
+ "@owlmeans/context": "^0.1.6",
29
+ "@owlmeans/mailer": "^0.1.6",
30
+ "@owlmeans/oidc": "^0.1.6",
31
+ "@owlmeans/redis-resource": "^0.1.6",
32
+ "@owlmeans/resource": "^0.1.6",
33
+ "@owlmeans/server-auth": "^0.1.6",
34
+ "@owlmeans/server-auth-identity": "^0.1.6",
35
+ "@owlmeans/server-context": "^0.1.6"
36
+ },
37
+ "devDependencies": {
38
+ "@owlmeans/dep-config": "workspace:*",
39
+ "@types/bun": "^1.3.0",
40
+ "nodemon": "^3.1.11",
41
+ "npm-check": "^6.0.1",
42
+ "typescript": "^6.0.2"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,4 @@
1
+ export const SERVER_AUTH_OTP = 'server-auth-otp'
2
+
3
+ /** The OTP auth plugin type key — passed as credential.type in init requests */
4
+ export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp'
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './service.js'
4
+ export * from './plugin.js'
package/src/plugin.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { ALL_SCOPES, AuthenFailed, AuthRole, AuthenticationType } from '@owlmeans/auth'
2
+ import type { AuthPlugin } from '@owlmeans/server-auth/manager/plugins'
3
+ import { registerPlugin } from '@owlmeans/server-auth/manager/plugins'
4
+ import type { OtpService } from '@owlmeans/auth-otp'
5
+ import { OTP_AUTH_TYPE, OTP_SERVICE } from '@owlmeans/auth-otp'
6
+ import type { IdentityLinkingService } from '@owlmeans/server-auth-identity'
7
+ import { AUTH_IDENTITY_LINKING } from '@owlmeans/server-auth-identity'
8
+ import type { OtpConfig, OtpContext } from './types.js'
9
+
10
+ /** Factory that creates the email-OTP AuthPlugin bound to the given context. */
11
+ const otpPlugin = <C extends OtpConfig, T extends OtpContext<C>>(context: T): AuthPlugin => ({
12
+ type: OTP_AUTH_TYPE,
13
+
14
+ init: async request => {
15
+ if (request.userId == null || !request.userId.includes('@')) {
16
+ throw new AuthenFailed('otp:email')
17
+ }
18
+ const email = request.userId.trim().toLowerCase()
19
+ const otp = context.service<OtpService>(OTP_SERVICE)
20
+ await otp.issueChallenge(email)
21
+ // Store the email in the challenge so the envelope preserves it for authenticate.
22
+ return { challenge: email }
23
+ },
24
+
25
+ authenticate: async credential => {
26
+ // credential.challenge is the email (opened from the signed envelope in makeAuthModel).
27
+ const email = credential.challenge
28
+ const code = credential.credential
29
+
30
+ if (!email || !email.includes('@')) {
31
+ throw new AuthenFailed('otp:email')
32
+ }
33
+ if (!code) {
34
+ throw new AuthenFailed('otp:code')
35
+ }
36
+
37
+ const otp = context.service<OtpService>(OTP_SERVICE)
38
+ await otp.verifyChallenge(email, code)
39
+
40
+ const identityAlias = (context.cfg as any).otp?.identityAlias ?? AUTH_IDENTITY_LINKING
41
+ const identity = context.service<IdentityLinkingService>(identityAlias)
42
+
43
+ const entityId = credential.entityId
44
+ const details = {
45
+ type: OTP_AUTH_TYPE,
46
+ service: 'email',
47
+ clientId: entityId ?? 'default',
48
+ userId: email,
49
+ entityId,
50
+ }
51
+
52
+ let payload = await identity.getLinkedProfile(details)
53
+ if (payload == null) {
54
+ payload = await identity.linkProfile(details, { username: email })
55
+ }
56
+
57
+ // Set the resulting credential fields for the envelope.
58
+ credential.type = AuthenticationType.OneTimeToken
59
+ credential.userId = payload.userId
60
+ credential.profileId = payload.profileId
61
+ credential.entityId = payload.entityId
62
+ credential.role = payload.role ?? AuthRole.User
63
+ credential.scopes = payload.scopes ?? [ALL_SCOPES]
64
+
65
+ return { token: '' }
66
+ },
67
+ })
68
+
69
+ /** Register the email-OTP plugin into the server-auth plugin registry. Call once at context setup. */
70
+ export const appendOtpPlugin = <C extends OtpConfig, T extends OtpContext<C>>(context: T): T => {
71
+ registerPlugin(OTP_AUTH_TYPE, otpPlugin as Parameters<typeof registerPlugin>[1])
72
+ return context
73
+ }
package/src/service.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { appendContextual } from '@owlmeans/context'
2
+ import { AuthenFailed } from '@owlmeans/auth'
3
+ import type { OtpService } from '@owlmeans/auth-otp'
4
+ import { OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp'
5
+ import type { RedisResource } from '@owlmeans/redis-resource'
6
+ import type { ResourceRecord } from '@owlmeans/resource'
7
+ import type { MailerService } from '@owlmeans/mailer'
8
+ import { MAILER_SERVICE } from '@owlmeans/mailer'
9
+ import type { OtpConfig, OtpContext } from './types.js'
10
+
11
+ interface OtpRecord extends ResourceRecord {
12
+ id: string
13
+ code: string
14
+ }
15
+
16
+ const codeKey = (email: string): string =>
17
+ email.toLowerCase().replace(/[^a-z0-9]/g, '_')
18
+
19
+ export const makeOtpService = (alias = OTP_SERVICE): OtpService => {
20
+ const service: OtpService = appendContextual<OtpService>(alias, {
21
+ issueChallenge: async (email: string): Promise<void> => {
22
+ const ctx = service.ctx as OtpContext<OtpConfig>
23
+ const resourceAlias = ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE
24
+ const mailerAlias = ctx.cfg.otp?.mailerAlias ?? MAILER_SERVICE
25
+ const resource = ctx.resource<RedisResource<OtpRecord>>(resourceAlias)
26
+ const mailer = ctx.service<MailerService>(mailerAlias)
27
+
28
+ const code = generateCode()
29
+ const id = codeKey(email)
30
+
31
+ // Upsert: delete previous code if any, then create fresh.
32
+ try {
33
+ await resource.delete(id)
34
+ } catch { /* key may not exist */ }
35
+
36
+ await resource.create({ id, code }, { ttl: OTP_TTL_SECONDS })
37
+
38
+ await mailer.send({
39
+ to: email,
40
+ subject: 'Your login code',
41
+ text: `Your one-time login code is: ${code}\n\nIt expires in ${OTP_TTL_SECONDS / 60} minutes.`,
42
+ html: `<p>Your one-time login code is: <strong>${code}</strong></p><p>It expires in ${OTP_TTL_SECONDS / 60} minutes.</p>`,
43
+ })
44
+ },
45
+
46
+ verifyChallenge: async (email: string, code: string): Promise<void> => {
47
+ const ctx = service.ctx as OtpContext<OtpConfig>
48
+ const resourceAlias = ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE
49
+ const resource = ctx.resource<RedisResource<OtpRecord>>(resourceAlias)
50
+
51
+ const id = codeKey(email)
52
+ const record = await resource.load(id)
53
+
54
+ if (record == null || record.code !== code.trim()) {
55
+ throw new AuthenFailed('otp:code')
56
+ }
57
+
58
+ // Consume: delete so the code cannot be reused.
59
+ try {
60
+ await resource.delete(id)
61
+ } catch { /* already expired */ }
62
+ },
63
+ })
64
+
65
+ return service
66
+ }
67
+
68
+ const generateCode = (): string => {
69
+ const digits = Math.floor(Math.random() * Math.pow(10, OTP_CODE_LENGTH))
70
+ return String(digits).padStart(OTP_CODE_LENGTH, '0')
71
+ }
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
2
+
3
+ export interface OtpConfig extends ServerConfig {
4
+ otp?: {
5
+ /** Alias of the registered MailerService. Defaults to MAILER_SERVICE. */
6
+ mailerAlias?: string
7
+ /** Alias of the Redis resource for code storage. Defaults to OTP_RESOURCE. */
8
+ resourceAlias?: string
9
+ /** Alias of the IdentityLinkingService. Defaults to AUTH_IDENTITY_LINKING. */
10
+ identityAlias?: string
11
+ }
12
+ }
13
+
14
+ export interface OtpContext<C extends OtpConfig = OtpConfig> extends ServerContext<C> {}
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["@owlmeans/dep-config/tsconfig.base.json", "@owlmeans/dep-config/tsconfig.server.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./build/"
6
+ },
7
+ "include": ["src"]
8
+ }