diff --git a/package.json b/package.json index dc2d341..a5837b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authsignal/browser", - "version": "0.0.22", + "version": "0.1.0", "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", @@ -21,13 +21,16 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^3.3.6", + "@simplewebauthn/browser": "^7.2.0", "a11y-dialog": "^7.5.2", "iframe-resizer": "^4.3.6", + "ky": "^0.33.3", "uuid": "^9.0.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^14.1.0", "@rollup/plugin-typescript": "^8.5.0", + "@simplewebauthn/typescript-types": "^7.0.0", "@types/iframe-resizer": "^3.5.9", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.39.0", diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..dce8ec3 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1 @@ +export * from "./passkey-api-client"; diff --git a/src/api/passkey-api-client.ts b/src/api/passkey-api-client.ts new file mode 100644 index 0000000..aa72aa1 --- /dev/null +++ b/src/api/passkey-api-client.ts @@ -0,0 +1,78 @@ +import ky from "ky"; +import {KyInstance} from "ky/distribution/types/ky"; +import { + AddAuthenticatorRequest, + AddAuthenticatorResponse, + AuthenticationOptsRequest, + AuthenticationOptsResponse, + RegistrationOptsRequest, + RegistrationOptsResponse, + VerifyRequest, + VerifyResponse, +} from "./types"; + +type PasskeyApiClientOptions = { + baseUrl: string; + tenantId: string; +}; + +export class PasskeyApiClient { + tenantId: string; + api: KyInstance; + + constructor({baseUrl, tenantId}: PasskeyApiClientOptions) { + this.tenantId = tenantId; + + this.api = ky.create({ + prefixUrl: baseUrl, + }); + } + + async registrationOptions({token, userName}: RegistrationOptsRequest): Promise { + const response = await this.api.post("user-authenticators/passkey/registration-options", { + json: {userName}, + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.json(); + } + + async authenticationOptions({token}: AuthenticationOptsRequest): Promise { + const authorizationHeader = token ? `Bearer ${token}` : `Basic ${Buffer.from(this.tenantId).toString("base64")}`; + + const response = await this.api.post("user-authenticators/passkey/authentication-options", { + json: {}, + headers: { + Authorization: authorizationHeader, + }, + }); + + return response.json(); + } + + async addAuthenticator({token, ...rest}: AddAuthenticatorRequest): Promise { + const response = await this.api.post("user-authenticators/passkey", { + json: rest, + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.json(); + } + + async verify({token, ...rest}: VerifyRequest): Promise { + const authorizationHeader = token ? `Bearer ${token}` : `Basic ${Buffer.from(this.tenantId).toString("base64")}`; + + const response = await this.api.post("verify/passkey", { + json: rest, + headers: { + Authorization: authorizationHeader, + }, + }); + + return response.json(); + } +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..a6ee2c4 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,47 @@ +import { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/typescript-types"; + +export type RegistrationOptsRequest = { + userName?: string; + token: string; +}; + +export type RegistrationOptsResponse = { + challengeId: string; + options: PublicKeyCredentialCreationOptionsJSON; +}; + +export type AuthenticationOptsRequest = { + token?: string; +}; + +export type AuthenticationOptsResponse = { + challengeId: string; + options: PublicKeyCredentialCreationOptionsJSON; +}; + +export type AddAuthenticatorRequest = { + token: string; + challengeId: string; + registrationCredential: RegistrationResponseJSON; +}; + +export type AddAuthenticatorResponse = { + isVerified: boolean; + accessToken?: string; + userAuthenticatorId?: string; +}; + +export type VerifyRequest = { + token?: string; + challengeId: string; + authenticationCredential: AuthenticationResponseJSON; +}; + +export type VerifyResponse = { + isVerified: boolean; + accessToken?: string; +}; diff --git a/src/authsignal.ts b/src/authsignal.ts index eae905e..73a0364 100644 --- a/src/authsignal.ts +++ b/src/authsignal.ts @@ -9,21 +9,30 @@ import { TokenPayload, } from "./types"; import {PopupHandler} from "./popup-handler"; +import {Passkey} from "./passkey"; const DEFAULT_COOKIE_NAME = "__as_aid"; +const DEFAULT_BASE_URL = "https://challenge.authsignal.com/v1"; + export class Authsignal { anonymousId = ""; cookieDomain = ""; anonymousIdCookieName = ""; - publishableKey = ""; + passkey: Passkey; private _token: string | undefined = undefined; - constructor({publishableKey, cookieDomain, cookieName}: AuthsignalOptions) { - this.publishableKey = publishableKey; + constructor({ + cookieDomain, + cookieName = DEFAULT_COOKIE_NAME, + baseUrl = DEFAULT_BASE_URL, + tenantId, + }: AuthsignalOptions) { this.cookieDomain = cookieDomain || getCookieDomain(); - this.anonymousIdCookieName = cookieName || DEFAULT_COOKIE_NAME; + this.anonymousIdCookieName = cookieName; + + this.passkey = new Passkey({tenantId, baseUrl}); const idCookie = getCookie(this.anonymousIdCookieName); diff --git a/src/passkey.ts b/src/passkey.ts new file mode 100644 index 0000000..917ea4a --- /dev/null +++ b/src/passkey.ts @@ -0,0 +1,63 @@ +import {startAuthentication, startRegistration} from "@simplewebauthn/browser"; + +import {PasskeyApiClient} from "./api"; + +type PasskeyOptions = { + baseUrl: string; + tenantId: string; +}; + +type SignUpParams = { + userName?: string; + token: string; +}; + +export class Passkey { + private api: PasskeyApiClient; + + constructor({baseUrl, tenantId}: PasskeyOptions) { + this.api = new PasskeyApiClient({baseUrl, tenantId}); + } + + async signUp({userName, token}: SignUpParams) { + const optionsResponse = await this.api.registrationOptions({userName, token}); + + try { + const registrationResponse = await startRegistration(optionsResponse.options); + + const addAuthenticatorResponse = await this.api.addAuthenticator({ + challengeId: optionsResponse.challengeId, + registrationCredential: registrationResponse, + token, + }); + + return addAuthenticatorResponse?.accessToken; + } catch (error) { + console.error(error); + } + } + + async signIn(params?: {token: string}): Promise; + async signIn(params?: {autofill: boolean}): Promise; + async signIn(params?: {token?: string; autofill?: boolean} | undefined) { + if (params?.token && params.autofill) { + throw new Error("Autofill is not supported when providing a token"); + } + + const optionsResponse = await this.api.authenticationOptions({token: params?.token}); + + try { + const authenticationResponse = await startAuthentication(optionsResponse.options, params?.autofill); + + const verifyResponse = await this.api.verify({ + challengeId: optionsResponse.challengeId, + authenticationCredential: authenticationResponse, + token: params?.token, + }); + + return verifyResponse?.accessToken; + } catch (error) { + console.error(error); + } + } +} diff --git a/src/types.ts b/src/types.ts index c84c81d..15da520 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,7 +27,6 @@ type PopupLaunchOptions = BaseLaunchOptions & { export type LaunchOptions = RedirectLaunchOptions | PopupLaunchOptions; export type AuthsignalOptions = { - publishableKey: string; /** * Cookie domain that will be used to identify * users. If not set, location.hostname will be used @@ -45,6 +44,8 @@ export type AuthsignalOptions = { * Name of id cookie. `__as_aid` by default */ cookieName?: string; + baseUrl?: string; + tenantId: string; }; export enum AuthsignalWindowMessage { diff --git a/yarn.lock b/yarn.lock index a6191a1..9939069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -98,6 +98,18 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@simplewebauthn/browser@^7.2.0": + version "7.2.0" + resolved "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-7.2.0.tgz#cb959290469d14b2f33e15156439e20caf539fc7" + integrity sha512-HHIvRPpqKy0UV/BsGAmx4rQRZuZTUFYLLH65FwpSOslqHruiHx3Ql/bq7A75bjWuJ296a+4BIAq3+SPaII01TQ== + dependencies: + "@simplewebauthn/typescript-types" "*" + +"@simplewebauthn/typescript-types@*", "@simplewebauthn/typescript-types@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@simplewebauthn/typescript-types/-/typescript-types-7.0.0.tgz#887178424220524e71c10ae4548fbb353ed58ae6" + integrity sha512-bV+xACCFTsrLR/23ozHO06ZllHZaxC8LlI5YCo79GvU2BrN+rePDU2yXwZIYndNWcMQwRdndRdAhpafOh9AC/g== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -746,6 +758,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +ky@^0.33.3: + version "0.33.3" + resolved "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz#bf1ad322a3f2c3428c13cfa4b3af95e6c4a2f543" + integrity sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw== + levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"