Skip to content

Commit

Permalink
feat: add TOTP MFA functions (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexCuse authored Jan 31, 2024
1 parent a1b9df9 commit 0f6a22c
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 6 deletions.
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ KeratinAuthN.signup(obj: {username: string, password: string}): Promise<void>
```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<void>
// OTP is only used by authn-server v1.18 and forward.
KeratinAuthN.login(obj: {username: string, password: string, otp?: string}): Promise<void>
```

```javascript
Expand Down Expand Up @@ -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<void>
// OTP is only used by authn-server v1.18 and forward.
KeratinAuthN.sessionTokenLogin(obj: {token: string, otp?: string}): Promise<void>
```

```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<OtpData>
```

```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<boolean>
```

```javascript
// Deletes a confirmed TOTP code for the authenticated user.
// Only available in authn-server v1.18 and forward.
KeratinAuthN.deleteTOTP(): Promise<boolean>
```

## Development
Expand Down
18 changes: 17 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +105,22 @@ export function sessionTokenLogin(credentials: {
);
}

export function newTOTP(): Promise<OtpData> {
return post<OtpData>(url("/totp/new"), {}).then((result: OtpData) => result);
}

export function confirmTOTP(req: { otp: string }): Promise<boolean> {
return post<void>(url("/totp/confirm"), req)
.then(() => true)
.catch(() => false);
}

export function deleteTOTP(): Promise<boolean> {
return del<void>(url("/totp"))
.then(() => true)
.catch(() => false);
}

function url(path: string): string {
if (!ISSUER.length) {
throw "ISSUER not set";
Expand Down
122 changes: 122 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
});
});
});
});
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Credentials, SessionStore } from "./types";
import { Credentials, SessionStore, OtpData } from "./types";
import SessionManager from "./SessionManager";
import CookieSessionStore, {
CookieSessionStoreOptions,
Expand Down Expand Up @@ -65,7 +65,10 @@ export function resetPassword(args: {
return API.resetPassword(args).then((token) => manager.update(token));
}

export function sessionTokenLogin(args: { token: string }): Promise<void> {
export function sessionTokenLogin(args: {
token: string;
otp?: string;
}): Promise<void> {
return API.sessionTokenLogin(args).then((token) => manager.update(token));
}

Expand All @@ -75,4 +78,7 @@ export {
isAvailable,
requestPasswordReset,
requestSessionToken,
newTOTP,
confirmTOTP,
deleteTOTP,
} from "./api";
8 changes: 7 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface Credentials {
[index: string]: string;
[index: string]: string | undefined;
username: string;
otp?: string;
password: string;
}

Expand Down Expand Up @@ -39,3 +40,8 @@ export interface SessionStore {
export interface StringMap {
[index: string]: string | undefined;
}

export interface OtpData {
secret: string;
url: string;
}

0 comments on commit 0f6a22c

Please sign in to comment.