Skip to content

Commit

Permalink
Add SDK methods for Email OTP/TOTP/SMS (#65)
Browse files Browse the repository at this point in the history
* totp

* fix import

* rest

* Replace token after verification

* Bump version
  • Loading branch information
hwhmeikle authored Aug 28, 2024
1 parent 249dbf8 commit db9aafe
Show file tree
Hide file tree
Showing 16 changed files with 383 additions and 26 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@authsignal/browser",
"version": "0.5.2",
"version": "0.5.3",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down
53 changes: 53 additions & 0 deletions src/api/email-api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {ApiClientOptions, ChallengeResponse, EnrollResponse, VerifyResponse} from "./types/shared";

export class EmailApiClient {
tenantId: string;
baseUrl: string;

constructor({baseUrl, tenantId}: ApiClientOptions) {
this.tenantId = tenantId;
this.baseUrl = baseUrl;
}

async enroll({token, email}: {token: string; email: string}): Promise<EnrollResponse> {
const body = {email};

const response = fetch(`${this.baseUrl}/client/user-authenticators/email-otp`, {
method: "POST",
headers: this.buildHeaders(token),
body: JSON.stringify(body),
});

return (await response).json();
}

async challenge({token}: {token: string}): Promise<ChallengeResponse> {
const response = fetch(`${this.baseUrl}/client/challenge/email-otp`, {
method: "POST",
headers: this.buildHeaders(token),
});

return (await response).json();
}

async verify({token, code}: {token: string; code: string}): Promise<VerifyResponse> {
const body = {code};

const response = fetch(`${this.baseUrl}/client/verify/email-otp`, {
method: "POST",
headers: this.buildHeaders(token),
body: JSON.stringify(body),
});

return (await response).json();
}

private buildHeaders(token?: string) {
const authorizationHeader = token ? `Bearer ${token}` : `Basic ${window.btoa(encodeURIComponent(this.tenantId))}`;

return {
"Content-Type": "application/json",
Authorization: authorizationHeader,
};
}
}
12 changes: 3 additions & 9 deletions src/api/passkey-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,19 @@ import {
AddAuthenticatorResponse,
AuthenticationOptsRequest,
AuthenticationOptsResponse,
AuthsignalResponse,
ChallengeResponse,
PasskeyAuthenticatorResponse,
RegistrationOptsRequest,
RegistrationOptsResponse,
VerifyRequest,
VerifyResponse,
} from "./types";

type PasskeyApiClientOptions = {
baseUrl: string;
tenantId: string;
};
} from "./types/passkey";
import {ApiClientOptions, AuthsignalResponse, ChallengeResponse} from "./types/shared";

export class PasskeyApiClient {
tenantId: string;
baseUrl: string;

constructor({baseUrl, tenantId}: PasskeyApiClientOptions) {
constructor({baseUrl, tenantId}: ApiClientOptions) {
this.tenantId = tenantId;
this.baseUrl = baseUrl;
}
Expand Down
53 changes: 53 additions & 0 deletions src/api/sms-api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {ApiClientOptions, ChallengeResponse, EnrollResponse, VerifyResponse} from "./types/shared";

export class SmsApiClient {
tenantId: string;
baseUrl: string;

constructor({baseUrl, tenantId}: ApiClientOptions) {
this.tenantId = tenantId;
this.baseUrl = baseUrl;
}

async enroll({token, phoneNumber}: {token: string; phoneNumber: string}): Promise<EnrollResponse> {
const body = {phoneNumber};

const response = fetch(`${this.baseUrl}/client/user-authenticators/sms`, {
method: "POST",
headers: this.buildHeaders(token),
body: JSON.stringify(body),
});

return (await response).json();
}

async challenge({token}: {token: string}): Promise<ChallengeResponse> {
const response = fetch(`${this.baseUrl}/client/challenge/sms`, {
method: "POST",
headers: this.buildHeaders(token),
});

return (await response).json();
}

async verify({token, code}: {token: string; code: string}): Promise<VerifyResponse> {
const body = {code};

const response = fetch(`${this.baseUrl}/client/verify/sms`, {
method: "POST",
headers: this.buildHeaders(token),
body: JSON.stringify(body),
});

return (await response).json();
}

private buildHeaders(token?: string) {
const authorizationHeader = token ? `Bearer ${token}` : `Basic ${window.btoa(encodeURIComponent(this.tenantId))}`;

return {
"Content-Type": "application/json",
Authorization: authorizationHeader,
};
}
}
42 changes: 42 additions & 0 deletions src/api/totp-api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {ApiClientOptions, VerifyResponse} from "./types/shared";
import {EnrollResponse} from "./types/totp";

export class TotpApiClient {
tenantId: string;
baseUrl: string;

constructor({baseUrl, tenantId}: ApiClientOptions) {
this.tenantId = tenantId;
this.baseUrl = baseUrl;
}

async enroll({token}: {token: string}): Promise<EnrollResponse> {
const response = fetch(`${this.baseUrl}/client/user-authenticators/totp`, {
method: "POST",
headers: this.buildHeaders(token),
});

return (await response).json();
}

async verify({token, code}: {token: string; code: string}): Promise<VerifyResponse> {
const body = {code};

const response = fetch(`${this.baseUrl}/client/verify/totp`, {
method: "POST",
headers: this.buildHeaders(token),
body: JSON.stringify(body),
});

return (await response).json();
}

private buildHeaders(token?: string) {
const authorizationHeader = token ? `Bearer ${token}` : `Basic ${window.btoa(encodeURIComponent(this.tenantId))}`;

return {
"Content-Type": "application/json",
Authorization: authorizationHeader,
};
}
}
11 changes: 0 additions & 11 deletions src/api/types.ts → src/api/types/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,3 @@ export type PasskeyAuthenticatorResponse = {
credentialId: string;
verifiedAt: string;
};

export type ChallengeResponse = {
challengeId: string;
};

export type ErrorResponse = {
error: string;
errorDescription?: string;
};

export type AuthsignalResponse<T> = T | ErrorResponse;
25 changes: 25 additions & 0 deletions src/api/types/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type ApiClientOptions = {
baseUrl: string;
tenantId: string;
};

export type EnrollResponse = {
userAuthenticatorId: string;
};

export type ChallengeResponse = {
challengeId: string;
};

export type VerifyResponse = {
isVerified: boolean;
token?: string;
failureReason?: string;
};

export type ErrorResponse = {
error: string;
errorDescription?: string;
};

export type AuthsignalResponse<T> = T | ErrorResponse;
5 changes: 5 additions & 0 deletions src/api/types/totp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type EnrollResponse = {
userAuthenticatorID: string;
uri: string;
secret: string;
};
15 changes: 15 additions & 0 deletions src/authsignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
} from "./types";
import {Passkey} from "./passkey";
import {PopupHandler, WindowHandler} from "./handlers";
import {Totp} from "./totp";
import {TokenCache} from "./token-cache";
import {Email} from "./email";
import {Sms} from "./sms";

const DEFAULT_COOKIE_NAME = "__as_aid";
const DEFAULT_PROFILING_COOKIE_NAME = "__as_pid";
Expand All @@ -25,7 +29,11 @@ export class Authsignal {
profilingId = "";
cookieDomain = "";
anonymousIdCookieName = "";

passkey: Passkey;
totp: Totp;
email: Email;
sms: Sms;

constructor({
cookieDomain,
Expand Down Expand Up @@ -57,6 +65,13 @@ export class Authsignal {
}

this.passkey = new Passkey({tenantId, baseUrl, anonymousId: this.anonymousId});
this.totp = new Totp({tenantId, baseUrl});
this.email = new Email({tenantId, baseUrl});
this.sms = new Sms({tenantId, baseUrl});
}

setToken(token: string) {
TokenCache.shared.token = token;
}

launch(url: string, options?: {mode?: "redirect"} & LaunchOptions): undefined;
Expand Down
55 changes: 55 additions & 0 deletions src/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {EmailApiClient} from "./api/email-api-client";
import {AuthsignalResponse, ChallengeResponse, EnrollResponse, VerifyResponse} from "./api/types/shared";
import {TokenCache} from "./token-cache";

type EmailOptions = {
baseUrl: string;
tenantId: string;
};

type EnrollParams = {
email: string;
};

type VerifyParams = {
code: string;
};

export class Email {
private api: EmailApiClient;
private cache = TokenCache.shared;

constructor({baseUrl, tenantId}: EmailOptions) {
this.api = new EmailApiClient({baseUrl, tenantId});
}

async enroll({email}: EnrollParams): Promise<AuthsignalResponse<EnrollResponse>> {
if (!this.cache.token) {
return this.cache.handleTokenNotSetError();
}

return this.api.enroll({token: this.cache.token, email});
}

async challenge(): Promise<AuthsignalResponse<ChallengeResponse>> {
if (!this.cache.token) {
return this.cache.handleTokenNotSetError();
}

return this.api.challenge({token: this.cache.token});
}

async verify({code}: VerifyParams): Promise<AuthsignalResponse<VerifyResponse>> {
if (!this.cache.token) {
return this.cache.handleTokenNotSetError();
}

const verifyResponse = await this.api.verify({token: this.cache.token, code});

if (verifyResponse.token) {
this.cache.token = verifyResponse.token;
}

return verifyResponse;
}
}
2 changes: 1 addition & 1 deletion src/handlers/popup-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class PopupHandler {
this.popup = new A11yDialog(container);

// Safari and Firefox will fail the WebAuthn request if the document making
// the request does not have focus. This will reduce the chances of that
// the request does not have focus. This will reduce the chances of that
// happening by focusing on the dialog container.
container.focus();

Expand Down
2 changes: 1 addition & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ErrorResponse} from "./api/types";
import {ErrorResponse} from "./api/types/shared";

type CookieOptions = {
name: string;
Expand Down
15 changes: 12 additions & 3 deletions src/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {startAuthentication, startRegistration} from "@simplewebauthn/browser";
import {PasskeyApiClient} from "./api";
import {AuthenticationResponseJSON, RegistrationResponseJSON, AuthenticatorAttachment} from "@simplewebauthn/types";
import {logErrorResponse} from "./helpers";
import {TokenCache} from "./token-cache";
import {AuthsignalResponse} from "./api/types/shared";

type PasskeyOptions = {
baseUrl: string;
Expand Down Expand Up @@ -40,6 +42,7 @@ export class Passkey {
public api: PasskeyApiClient;
private passkeyLocalStorageKey = "as_passkey_credential_id";
private anonymousId: string;
private cache = TokenCache.shared;

constructor({baseUrl, tenantId, anonymousId}: PasskeyOptions) {
this.api = new PasskeyApiClient({baseUrl, tenantId});
Expand All @@ -51,11 +54,17 @@ export class Passkey {
userDisplayName,
token,
authenticatorAttachment = "platform",
}: SignUpParams): Promise<SignUpResponse | undefined> {
}: SignUpParams): Promise<AuthsignalResponse<SignUpResponse | undefined>> {
const userToken = token ?? this.cache.token;

if (!userToken) {
return this.cache.handleTokenNotSetError();
}

const optionsInput = {
username: userName,
displayName: userDisplayName,
token,
token: userToken,
authenticatorAttachment,
};

Expand All @@ -71,7 +80,7 @@ export class Passkey {
const addAuthenticatorResponse = await this.api.addAuthenticator({
challengeId: optionsResponse.challengeId,
registrationCredential: registrationResponse,
token,
token: userToken,
});

if ("error" in addAuthenticatorResponse) {
Expand Down
Loading

0 comments on commit db9aafe

Please sign in to comment.