Skip to content

Commit 4870f8d

Browse files
authored
Add email magic link verification (#80)
1 parent de4dbca commit 4870f8d

File tree

5 files changed

+137
-1
lines changed

5 files changed

+137
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@authsignal/browser",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"type": "module",
55
"main": "dist/index.js",
66
"module": "dist/index.js",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {ChallengeResponse, EnrollResponse} from "../types";
2+
import {buildHeaders, handleTokenExpired} from "./helpers";
3+
import {CheckVerificationStatusResponse} from "./types/email-magic-link";
4+
import {ApiClientOptions, ErrorResponse} from "./types/shared";
5+
6+
export class EmailMagicLinkApiClient {
7+
tenantId: string;
8+
baseUrl: string;
9+
onTokenExpired?: () => void;
10+
11+
constructor({baseUrl, tenantId, onTokenExpired}: ApiClientOptions) {
12+
this.tenantId = tenantId;
13+
this.baseUrl = baseUrl;
14+
this.onTokenExpired = onTokenExpired;
15+
}
16+
17+
async enroll({token, email}: {token: string; email: string}): Promise<EnrollResponse | ErrorResponse> {
18+
const body = {email};
19+
20+
const response = await fetch(`${this.baseUrl}/client/user-authenticators/email-magic-link`, {
21+
method: "POST",
22+
headers: buildHeaders({token, tenantId: this.tenantId}),
23+
body: JSON.stringify(body),
24+
});
25+
26+
const responseJson = await response.json();
27+
28+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
29+
30+
return responseJson;
31+
}
32+
33+
async challenge({token}: {token: string}): Promise<ChallengeResponse | ErrorResponse> {
34+
const response = await fetch(`${this.baseUrl}/client/challenge/email-magic-link`, {
35+
method: "POST",
36+
headers: buildHeaders({token, tenantId: this.tenantId}),
37+
});
38+
39+
const responseJson = await response.json();
40+
41+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
42+
43+
return responseJson;
44+
}
45+
46+
async checkVerificationStatus({token}: {token: string}): Promise<CheckVerificationStatusResponse | ErrorResponse> {
47+
const pollVerificationStatus = async (): Promise<CheckVerificationStatusResponse | ErrorResponse> => {
48+
const response = await fetch(`${this.baseUrl}/client/verify/email-magic-link/finalize`, {
49+
method: "POST",
50+
headers: buildHeaders({token, tenantId: this.tenantId}),
51+
body: JSON.stringify({}),
52+
});
53+
54+
const responseJson = await response.json();
55+
56+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
57+
58+
if (responseJson.isVerified) {
59+
return responseJson;
60+
} else {
61+
return new Promise((resolve) => {
62+
setTimeout(async () => {
63+
resolve(await pollVerificationStatus());
64+
}, 1000);
65+
});
66+
}
67+
};
68+
69+
return await pollVerificationStatus();
70+
}
71+
}

src/api/types/email-magic-link.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type CheckVerificationStatusResponse = {
2+
isVerified: boolean;
3+
accessToken?: string;
4+
};

src/authsignal.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {Totp} from "./totp";
1616
import {TokenCache} from "./token-cache";
1717
import {Email} from "./email";
1818
import {Sms} from "./sms";
19+
import {EmailMagicLink} from "./email-magic-link";
1920

2021
const DEFAULT_COOKIE_NAME = "__as_aid";
2122
const DEFAULT_PROFILING_COOKIE_NAME = "__as_pid";
@@ -33,6 +34,7 @@ export class Authsignal {
3334
passkey: Passkey;
3435
totp: Totp;
3536
email: Email;
37+
emailML: EmailMagicLink;
3638
sms: Sms;
3739

3840
constructor({
@@ -68,6 +70,7 @@ export class Authsignal {
6870
this.passkey = new Passkey({tenantId, baseUrl, anonymousId: this.anonymousId, onTokenExpired});
6971
this.totp = new Totp({tenantId, baseUrl, onTokenExpired});
7072
this.email = new Email({tenantId, baseUrl, onTokenExpired});
73+
this.emailML = new EmailMagicLink({tenantId, baseUrl, onTokenExpired});
7174
this.sms = new Sms({tenantId, baseUrl, onTokenExpired});
7275
}
7376

src/email-magic-link.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {EmailMagicLinkApiClient} from "./api/email-magic-link-api-client";
2+
import {CheckVerificationStatusResponse} from "./api/types/email-magic-link";
3+
import {handleApiResponse} from "./helpers";
4+
import {TokenCache} from "./token-cache";
5+
import {AuthsignalResponse, ChallengeResponse, EnrollResponse} from "./types";
6+
7+
type EmailMagicLinkOptions = {
8+
baseUrl: string;
9+
tenantId: string;
10+
onTokenExpired?: () => void;
11+
};
12+
13+
type EnrollParams = {
14+
email: string;
15+
};
16+
17+
export class EmailMagicLink {
18+
private api: EmailMagicLinkApiClient;
19+
private cache = TokenCache.shared;
20+
21+
constructor({baseUrl, tenantId, onTokenExpired}: EmailMagicLinkOptions) {
22+
this.api = new EmailMagicLinkApiClient({baseUrl, tenantId, onTokenExpired});
23+
}
24+
25+
async enroll({email}: EnrollParams): Promise<AuthsignalResponse<EnrollResponse>> {
26+
if (!this.cache.token) {
27+
return this.cache.handleTokenNotSetError();
28+
}
29+
30+
const response = await this.api.enroll({token: this.cache.token, email});
31+
32+
return handleApiResponse(response);
33+
}
34+
35+
async challenge(): Promise<AuthsignalResponse<ChallengeResponse>> {
36+
if (!this.cache.token) {
37+
return this.cache.handleTokenNotSetError();
38+
}
39+
40+
const response = await this.api.challenge({token: this.cache.token});
41+
42+
return handleApiResponse(response);
43+
}
44+
45+
async checkVerificationStatus(): Promise<AuthsignalResponse<CheckVerificationStatusResponse>> {
46+
if (!this.cache.token) {
47+
return this.cache.handleTokenNotSetError();
48+
}
49+
50+
const response = await this.api.checkVerificationStatus({token: this.cache.token});
51+
52+
if ("accessToken" in response && response.accessToken) {
53+
this.cache.token = response.accessToken;
54+
}
55+
56+
return handleApiResponse(response);
57+
}
58+
}

0 commit comments

Comments
 (0)