Skip to content

Commit e6e36fb

Browse files
authored
Add security key support (#82)
* wip * wip * Fix endpoint
1 parent eb2d16d commit e6e36fb

File tree

4 files changed

+207
-1
lines changed

4 files changed

+207
-1
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@authsignal/browser",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"type": "module",
55
"main": "dist/index.js",
66
"module": "dist/index.js",

src/api/security-key-api-client.ts

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
AuthenticationResponseJSON,
3+
PublicKeyCredentialCreationOptionsJSON,
4+
PublicKeyCredentialRequestOptionsJSON,
5+
RegistrationResponseJSON,
6+
} from "@simplewebauthn/types";
7+
import {buildHeaders, handleTokenExpired} from "./helpers";
8+
import {AddAuthenticatorResponse, ErrorResponse, VerifyResponse} from "./types/passkey";
9+
import {ApiClientOptions} from "./types/shared";
10+
11+
export class SecurityKeyApiClient {
12+
tenantId: string;
13+
baseUrl: string;
14+
onTokenExpired?: () => void;
15+
16+
constructor({baseUrl, tenantId, onTokenExpired}: ApiClientOptions) {
17+
this.tenantId = tenantId;
18+
this.baseUrl = baseUrl;
19+
this.onTokenExpired = onTokenExpired;
20+
}
21+
22+
async registrationOptions({token}: {token: string}): Promise<PublicKeyCredentialCreationOptionsJSON | ErrorResponse> {
23+
const response = await fetch(`${this.baseUrl}/client/user-authenticators/security-key/registration-options`, {
24+
method: "POST",
25+
headers: buildHeaders({token, tenantId: this.tenantId}),
26+
body: JSON.stringify({}),
27+
});
28+
29+
const responseJson = await response.json();
30+
31+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
32+
33+
return responseJson;
34+
}
35+
36+
async authenticationOptions({
37+
token,
38+
}: {
39+
token?: string;
40+
}): Promise<PublicKeyCredentialRequestOptionsJSON | ErrorResponse> {
41+
const response = await fetch(`${this.baseUrl}/client/user-authenticators/security-key/authentication-options`, {
42+
method: "POST",
43+
headers: buildHeaders({token, tenantId: this.tenantId}),
44+
body: JSON.stringify({}),
45+
});
46+
47+
const responseJson = await response.json();
48+
49+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
50+
51+
return responseJson;
52+
}
53+
54+
async addAuthenticator({
55+
token,
56+
registrationCredential,
57+
}: {
58+
token: string;
59+
registrationCredential: RegistrationResponseJSON;
60+
}): Promise<AddAuthenticatorResponse | ErrorResponse> {
61+
const response = await fetch(`${this.baseUrl}/client/user-authenticators/security-key`, {
62+
method: "POST",
63+
headers: buildHeaders({token, tenantId: this.tenantId}),
64+
body: JSON.stringify(registrationCredential),
65+
});
66+
67+
const responseJson = await response.json();
68+
69+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
70+
71+
return responseJson;
72+
}
73+
74+
async verify({
75+
token,
76+
authenticationCredential,
77+
}: {
78+
token?: string;
79+
authenticationCredential: AuthenticationResponseJSON;
80+
}): Promise<VerifyResponse | ErrorResponse> {
81+
const response = await fetch(`${this.baseUrl}/client/verify/security-key`, {
82+
method: "POST",
83+
headers: buildHeaders({token, tenantId: this.tenantId}),
84+
body: JSON.stringify(authenticationCredential),
85+
});
86+
87+
const responseJson = await response.json();
88+
89+
handleTokenExpired({response: responseJson, onTokenExpired: this.onTokenExpired});
90+
91+
return responseJson;
92+
}
93+
}

src/authsignal.ts

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {TokenCache} from "./token-cache";
1717
import {Email} from "./email";
1818
import {Sms} from "./sms";
1919
import {EmailMagicLink} from "./email-magic-link";
20+
import {SecurityKey} from "./security-key";
2021

2122
const DEFAULT_COOKIE_NAME = "__as_aid";
2223
const DEFAULT_PROFILING_COOKIE_NAME = "__as_pid";
@@ -36,6 +37,7 @@ export class Authsignal {
3637
email: Email;
3738
emailML: EmailMagicLink;
3839
sms: Sms;
40+
securityKey: SecurityKey;
3941

4042
constructor({
4143
cookieDomain,
@@ -72,6 +74,7 @@ export class Authsignal {
7274
this.email = new Email({tenantId, baseUrl, onTokenExpired});
7375
this.emailML = new EmailMagicLink({tenantId, baseUrl, onTokenExpired});
7476
this.sms = new Sms({tenantId, baseUrl, onTokenExpired});
77+
this.securityKey = new SecurityKey({tenantId, baseUrl, onTokenExpired});
7578
}
7679

7780
setToken(token: string) {

src/security-key.ts

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {startAuthentication, startRegistration} from "@simplewebauthn/browser";
2+
3+
import {AuthenticationResponseJSON, RegistrationResponseJSON} from "@simplewebauthn/types";
4+
import {TokenCache} from "./token-cache";
5+
import {handleErrorResponse} from "./helpers";
6+
import {AuthsignalResponse} from "./types";
7+
import {SecurityKeyApiClient} from "./api/security-key-api-client";
8+
9+
type SecurityKeyOptions = {
10+
baseUrl: string;
11+
tenantId: string;
12+
onTokenExpired?: () => void;
13+
};
14+
15+
type EnrollResponse = {
16+
token?: string;
17+
registrationResponse?: RegistrationResponseJSON;
18+
};
19+
20+
type VerifyResponse = {
21+
isVerified: boolean;
22+
token?: string;
23+
authenticationResponse?: AuthenticationResponseJSON;
24+
};
25+
26+
export class SecurityKey {
27+
public api: SecurityKeyApiClient;
28+
private cache = TokenCache.shared;
29+
30+
constructor({baseUrl, tenantId, onTokenExpired}: SecurityKeyOptions) {
31+
this.api = new SecurityKeyApiClient({baseUrl, tenantId, onTokenExpired});
32+
}
33+
34+
async enroll(): Promise<AuthsignalResponse<EnrollResponse>> {
35+
if (!this.cache.token) {
36+
return this.cache.handleTokenNotSetError();
37+
}
38+
39+
const optionsInput = {
40+
token: this.cache.token,
41+
};
42+
43+
const optionsResponse = await this.api.registrationOptions(optionsInput);
44+
45+
if ("error" in optionsResponse) {
46+
return handleErrorResponse(optionsResponse);
47+
}
48+
49+
const registrationResponse = await startRegistration(optionsResponse);
50+
51+
const addAuthenticatorResponse = await this.api.addAuthenticator({
52+
registrationCredential: registrationResponse,
53+
token: this.cache.token,
54+
});
55+
56+
if ("error" in addAuthenticatorResponse) {
57+
return handleErrorResponse(addAuthenticatorResponse);
58+
}
59+
60+
if (addAuthenticatorResponse.accessToken) {
61+
this.cache.token = addAuthenticatorResponse.accessToken;
62+
}
63+
64+
return {
65+
data: {
66+
token: addAuthenticatorResponse.accessToken,
67+
registrationResponse,
68+
},
69+
};
70+
}
71+
72+
async verify(): Promise<AuthsignalResponse<VerifyResponse>> {
73+
if (!this.cache.token) {
74+
return this.cache.handleTokenNotSetError();
75+
}
76+
77+
const optionsResponse = await this.api.authenticationOptions({
78+
token: this.cache.token,
79+
});
80+
81+
if ("error" in optionsResponse) {
82+
return handleErrorResponse(optionsResponse);
83+
}
84+
85+
const authenticationResponse = await startAuthentication(optionsResponse);
86+
87+
const verifyResponse = await this.api.verify({
88+
authenticationCredential: authenticationResponse,
89+
token: this.cache.token,
90+
});
91+
92+
if ("error" in verifyResponse) {
93+
return handleErrorResponse(verifyResponse);
94+
}
95+
96+
if (verifyResponse.accessToken) {
97+
this.cache.token = verifyResponse.accessToken;
98+
}
99+
100+
const {accessToken: token, isVerified} = verifyResponse;
101+
102+
return {
103+
data: {
104+
isVerified,
105+
token,
106+
authenticationResponse,
107+
},
108+
};
109+
}
110+
}

0 commit comments

Comments
 (0)