From 6720d8c32fc2d54a18e685d844d1ebd36cc7c40d Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Thu, 23 Nov 2023 14:16:09 -0500 Subject: [PATCH 1/6] feat: add TOTP MFA functions --- src/api.ts | 22 +++++++++++++++++++++- src/index.ts | 5 ++++- src/types.ts | 6 ++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 9b5b8f6..b844ae9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,7 +2,7 @@ * Bare API methods have no local side effects (unless you count debouncing). */ -import { Credentials, KeratinError } from "./types"; +import { Credentials, KeratinError, OtpData } from "./types"; import { get, post, del } from "./verbs"; // TODO: extract debouncing @@ -105,6 +105,26 @@ export function sessionTokenLogin(credentials: { ); } +export function newTOTP() : Promise { + return post(url("/totp/new"), {}).then( + (result: OtpData) => result + ) +} + +export function confirmTOTP(req: { + otp: string; +}) : Promise { + return post(url("/totp/confirm"), req) + .then(() => true) + .catch(() => false); +} + +export function deleteTOTP() : Promise { + return del(url("/totp")) + .then(() => true) + .catch(() => false); +} + function url(path: string): string { if (!ISSUER.length) { throw "ISSUER not set"; diff --git a/src/index.ts b/src/index.ts index 43b824d..e08d91c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Credentials, SessionStore } from "./types"; +import { Credentials, SessionStore, OtpData } from "./types"; import SessionManager from "./SessionManager"; import CookieSessionStore, { CookieSessionStoreOptions, @@ -75,4 +75,7 @@ export { isAvailable, requestPasswordReset, requestSessionToken, + newTOTP, + confirmTOTP, + deleteTOTP, } from "./api"; diff --git a/src/types.ts b/src/types.ts index f041aca..1bacafb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface Credentials { [index: string]: string; username: string; + otp: string; password: string; } @@ -39,3 +40,8 @@ export interface SessionStore { export interface StringMap { [index: string]: string | undefined; } + +export interface OtpData { + secret: string; + url: string; +} From 8f047ed03e2452ef3cec944c4af0e669d6f157b0 Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Tue, 12 Dec 2023 17:00:35 -0500 Subject: [PATCH 2/6] update README + run prettier --- README.md | 22 +++++++++++++++++++++- src/api.ts | 20 ++++++++------------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 364dcd2..d71e530 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ KeratinAuthN.signup(obj: {username: string, password: string}): Promise ```javascript // Returns a Promise that is fulfilled when a successful login has established a session. // May error with generic validation failures. -KeratinAuthN.login(obj: {username: string, password: string}): Promise +// OTP is only used in v1.18 and forward. +KeratinAuthN.login(obj: {username: string, password: string, otp: string}): Promise ``` ```javascript @@ -139,6 +140,25 @@ KeratinAuthN.requestSessionToken(username: string): Promise<> KeratinAuthN.sessionTokenLogin(obj: {token: string}): Promise ``` +```javascript +// Creates a new TOTP code for the logged in user. Returns an object containing the raw secret and +// TOTP onboarding URL. +// Only available in authn-server v1.18 and forward. +KeratinAuthN.newTOTP(obj: {token: string}): Promise +``` + +```javascript +// Confirms a pending TOTP code for the authenticated user. +// Only available in authn-server v1.18 and forward. +KeratinAuthN.confirmTOTP(obj: {otp: string}): Promise +``` + +```javascript +// Deletes a confirmed TOTP code for the authenticated user. +// Only available in authn-server v1.18 and forward. +KeratinAuthN.deleteTOTP(): Promise +``` + ## Development Embrace the TypeScript! diff --git a/src/api.ts b/src/api.ts index b844ae9..ff77f18 100644 --- a/src/api.ts +++ b/src/api.ts @@ -105,24 +105,20 @@ export function sessionTokenLogin(credentials: { ); } -export function newTOTP() : Promise { - return post(url("/totp/new"), {}).then( - (result: OtpData) => result - ) +export function newTOTP(): Promise { + return post(url("/totp/new"), {}).then((result: OtpData) => result); } -export function confirmTOTP(req: { - otp: string; -}) : Promise { +export function confirmTOTP(req: { otp: string }): Promise { return post(url("/totp/confirm"), req) - .then(() => true) - .catch(() => false); + .then(() => true) + .catch(() => false); } -export function deleteTOTP() : Promise { +export function deleteTOTP(): Promise { return del(url("/totp")) - .then(() => true) - .catch(() => false); + .then(() => true) + .catch(() => false); } function url(path: string): string { From 11b1aecc27e482c6c825a69a79b2c39076b3db7c Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Fri, 19 Jan 2024 21:21:19 -0500 Subject: [PATCH 3/6] fix credentials type --- src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index 1bacafb..1cfde5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ export interface Credentials { - [index: string]: string; + [index: string]: string | undefined; username: string; - otp: string; + otp?: string; password: string; } From faa8e990485dca00ea600c1fbac6eb81c1ee4449 Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Fri, 19 Jan 2024 21:38:27 -0500 Subject: [PATCH 4/6] add tests with old MSW version --- src/index.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 0fdb74b..7adc118 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -253,6 +253,22 @@ describe("login", () => { expect(readCookie("authn")).toEqual(token); }); + test("success OTP", async () => { + server.use( + rest.post( + "https://authn.example.com/session", + resultResolver({ id_token: idToken({ age: 1 }) }) + ) + ); + + await AuthN.login({ username: "test", password: "test", otp: "555667" }); + + const token = AuthN.session(); + expect(token!.length).toBeGreaterThan(0); + expect(token!.split(".")).toHaveLength(3); + expect(readCookie("authn")).toEqual(token); + }); + test("failure", async () => { server.use( rest.post( @@ -416,3 +432,93 @@ describe("sessionTokenLogin", () => { ).rejects.toEqual([{ field: "foo", message: "bar" }]); }); }); + +describe("totp", () => { + describe("newTOTP", () => { + test("success", async () => { + server.use( + rest.post( + "https://authn.example.com/totp/new", + resultResolver({ otp: "55567", secret: "xxx" }) + ) + ); + + const res = await AuthN.newTOTP(); + + expect(res).toEqual({ otp: "55567", secret: "xxx" }); + }); + + test("failure", async () => { + server.use( + rest.post( + "https://authn.example.com/totp/new", + errorsResolver({ foo: "bar" }) + ) + ); + + await expect(AuthN.newTOTP()).rejects.toEqual([ + { field: "foo", message: "bar" }, + ]); + }); + }); + + describe("confirmTOTP", () => { + test("success", async () => { + server.use( + rest.post( + "https://authn.example.com/totp/confirm", + resultResolver(undefined) + ) + ); + + const res = await AuthN.confirmTOTP({ + otp: "555667", + }); + + expect(res).toBe(true); + }); + + test("failure", async () => { + server.use( + rest.post( + "https://authn.example.com/totp/confirm", + errorsResolver({ foo: "bar" }) + ) + ); + + const res = await AuthN.confirmTOTP({ + otp: "555667", + }); + + expect(res).toEqual(false); + }); + + describe("deleteTOTP", () => { + test("success", async () => { + server.use( + rest.delete( + "https://authn.example.com/totp", + resultResolver(undefined) + ) + ); + + const res = await AuthN.deleteTOTP(); + + expect(res).toBe(true); + }); + + test("failure", async () => { + server.use( + rest.delete( + "https://authn.example.com/totp", + errorsResolver({ foo: "bar" }) + ) + ); + + const res = await AuthN.deleteTOTP(); + + expect(res).toEqual(false); + }); + }); + }); +}); From b40f47deeeb72bdeb487a40e03e0189f62770019 Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Sat, 20 Jan 2024 21:15:04 -0500 Subject: [PATCH 5/6] note otp is optional for login in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d71e530..fc084e3 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ KeratinAuthN.signup(obj: {username: string, password: string}): Promise // Returns a Promise that is fulfilled when a successful login has established a session. // May error with generic validation failures. // OTP is only used in v1.18 and forward. -KeratinAuthN.login(obj: {username: string, password: string, otp: string}): Promise +KeratinAuthN.login(obj: {username: string, password: string, otp?: string}): Promise ``` ```javascript From 853c10d37c280bf7a145213c70d9491c0ab444f3 Mon Sep 17 00:00:00 2001 From: Alex Ullrich Date: Sat, 20 Jan 2024 21:22:20 -0500 Subject: [PATCH 6/6] add optional OTP for session token login --- README.md | 5 +++-- src/index.test.ts | 16 ++++++++++++++++ src/index.ts | 5 ++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fc084e3..81567b0 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ KeratinAuthN.signup(obj: {username: string, password: string}): Promise ```javascript // Returns a Promise that is fulfilled when a successful login has established a session. // May error with generic validation failures. -// OTP is only used in v1.18 and forward. +// OTP is only used by authn-server v1.18 and forward. KeratinAuthN.login(obj: {username: string, password: string, otp?: string}): Promise ``` @@ -137,7 +137,8 @@ KeratinAuthN.requestSessionToken(username: string): Promise<> // Establishes a session with the session token. // May error with invalid/expired tokens, or if a login (username/password) is made after request the // token. -KeratinAuthN.sessionTokenLogin(obj: {token: string}): Promise +// OTP is only used by authn-server v1.18 and forward. +KeratinAuthN.sessionTokenLogin(obj: {token: string, otp?: string}): Promise ``` ```javascript diff --git a/src/index.test.ts b/src/index.test.ts index 7adc118..ea94bcd 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -417,6 +417,22 @@ describe("sessionTokenLogin", () => { expect(readCookie("authn")).toEqual(token); }); + test("success OTP", async () => { + server.use( + rest.post( + "https://authn.example.com/session/token", + resultResolver({ id_token: idToken({ age: 1 }) }) + ) + ); + + await AuthN.sessionTokenLogin({ token: "test", otp: "556677" }); + + const token = AuthN.session(); + expect(token!.length).toBeGreaterThan(0); + expect(token!.split(".")).toHaveLength(3); + expect(readCookie("authn")).toEqual(token); + }); + test("failure", async () => { server.use( rest.post( diff --git a/src/index.ts b/src/index.ts index e08d91c..b1d4dcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,7 +65,10 @@ export function resetPassword(args: { return API.resetPassword(args).then((token) => manager.update(token)); } -export function sessionTokenLogin(args: { token: string }): Promise { +export function sessionTokenLogin(args: { + token: string; + otp?: string; +}): Promise { return API.sessionTokenLogin(args).then((token) => manager.update(token)); }