Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add TOTP MFA functions #60

Merged
merged 6 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}