diff --git a/README.md b/README.md index 364dcd2..81567b0 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 by authn-server v1.18 and forward. +KeratinAuthN.login(obj: {username: string, password: string, otp?: string}): Promise ``` ```javascript @@ -136,7 +137,27 @@ 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 +// 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 diff --git a/src/api.ts b/src/api.ts index 9b5b8f6..ff77f18 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,22 @@ 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.test.ts b/src/index.test.ts index 0fdb74b..ea94bcd 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( @@ -401,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( @@ -416,3 +448,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); + }); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 43b824d..b1d4dcf 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, @@ -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)); } @@ -75,4 +78,7 @@ export { isAvailable, requestPasswordReset, requestSessionToken, + newTOTP, + confirmTOTP, + deleteTOTP, } from "./api"; diff --git a/src/types.ts b/src/types.ts index f041aca..1cfde5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface Credentials { - [index: string]: string; + [index: string]: string | undefined; 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; +}