Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
57 changes: 25 additions & 32 deletions src/create-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import {
} from "./interfaces";
import { NoClientIdProvidedException } from "./exceptions";
import {
authenticateWithCode,
authenticateWithRefreshToken,
getAuthorizationUrl,
getLogoutUrl,
isRedirectCallback,
memoryStorage,
createPkceChallenge,
Expand All @@ -20,6 +16,7 @@ import { getRefreshToken, getClaims } from "./utils/session-data";
import { RedirectParams } from "./interfaces/create-client-options.interface";
import { LoginRequiredError, RefreshError } from "./errors";
import { withLock, LockError } from "./utils/locking";
import { HttpClient } from "./http-client";

interface RedirectOptions {
context?: string;
Expand All @@ -33,8 +30,6 @@ interface RedirectOptions {

type State = "INITIAL" | "AUTHENTICATING" | "AUTHENTICATED" | "ERROR";

const DEFAULT_HOSTNAME = "api.workos.com";

const ORGANIZATION_ID_SESSION_STORAGE_KEY = "workos_organization_id";

const REFRESH_LOCK_NAME = "WORKOS_REFRESH_SESSION";
Expand All @@ -43,9 +38,8 @@ export class Client {
#state: State;
#refreshTimer: ReturnType<typeof setTimeout> | undefined;

readonly #clientId: string;
readonly #httpClient: HttpClient;
readonly #redirectUri: string;
readonly #baseUrl: string;
readonly #devMode: boolean;
readonly #onBeforeAutoRefresh: () => boolean;
readonly #onRedirectCallback: (params: RedirectParams) => void;
Expand All @@ -58,8 +52,8 @@ export class Client {
constructor(
clientId: string,
{
apiHostname = DEFAULT_HOSTNAME,
https = true,
apiHostname: hostname,
https,
port,
redirectUri = window.origin,
devMode = location.hostname === "localhost" ||
Expand All @@ -78,12 +72,9 @@ export class Client {
throw new NoClientIdProvidedException();
}

this.#httpClient = new HttpClient({ clientId, hostname, port, https });
this.#devMode = devMode;
this.#clientId = clientId;
this.#redirectUri = redirectUri;
this.#baseUrl = `${https ? "https" : "http"}://${apiHostname}${
port ? `:${port}` : ""
}`;
this.#state = "INITIAL";
this.#onBeforeAutoRefresh = onBeforeAutoRefresh;
this.#onRedirectCallback = onRedirectCallback;
Expand Down Expand Up @@ -119,8 +110,13 @@ export class Client {
return this.#redirect({ ...opts, type: "sign-up" });
}

signOut() {
const url = getLogoutUrl(this.#baseUrl);
signOut(): void {
const accessToken = memoryStorage.getItem(storageKeys.accessToken);
if (typeof accessToken !== "string") return;
const { sid: sessionId } = getClaims(accessToken);

const url = this.#httpClient.getLogoutUrl(sessionId);

if (url) {
removeSessionData({ devMode: this.#devMode });
window.location.assign(url);
Expand Down Expand Up @@ -177,13 +173,12 @@ export class Client {
if (code) {
if (codeVerifier) {
try {
const authenticationResponse = await authenticateWithCode({
baseUrl: this.#baseUrl,
clientId: this.#clientId,
code,
codeVerifier,
useCookie: this.#useCookie,
});
const authenticationResponse =
await this.#httpClient.authenticateWithCode({
code,
codeVerifier,
useCookie: this.#useCookie,
});

if (authenticationResponse) {
this.#state = "AUTHENTICATED";
Expand Down Expand Up @@ -251,13 +246,12 @@ An authorization_code was supplied for a login which did not originate at the ap
}
}

const authenticationResponse = await authenticateWithRefreshToken({
baseUrl: this.#baseUrl,
clientId: this.#clientId,
refreshToken: getRefreshToken({ devMode: this.#devMode }),
organizationId,
useCookie: this.#useCookie,
});
const authenticationResponse =
await this.#httpClient.authenticateWithRefreshToken({
refreshToken: getRefreshToken({ devMode: this.#devMode }),
organizationId,
useCookie: this.#useCookie,
});

this.#state = "AUTHENTICATED";
setSessionData(authenticationResponse, { devMode: this.#devMode });
Expand Down Expand Up @@ -330,8 +324,7 @@ An authorization_code was supplied for a login which did not originate at the ap
const { codeVerifier, codeChallenge } = await createPkceChallenge();
// store the code verifier in session storage for later use (after the redirect back from authkit)
window.sessionStorage.setItem(storageKeys.codeVerifier, codeVerifier);
const url = getAuthorizationUrl(this.#baseUrl, {
clientId: this.#clientId,
const url = this.#httpClient.getAuthorizationUrl({
codeChallenge,
codeChallengeMethod: "S256",
context,
Expand Down
46 changes: 46 additions & 0 deletions src/http-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { HttpClient } from "./http-client";

describe("HttpClient", () => {
let httpClient: HttpClient;

beforeEach(() => {
httpClient = new HttpClient({
clientId: "123",
});
});

describe("getAuthorizationUrl", () => {
it("returns an authorization URL with the given `baseUrl`", () => {
expect(httpClient.getAuthorizationUrl({ provider: "authkit" })).toBe(
"https://api.workos.com/user_management/authorize?client_id=123&provider=authkit&response_type=code",
);
});

describe("when no `provider`, `connectionId`, or `organizationId` is provided", () => {
it("throws a TypeError", () => {
expect(() => httpClient.getAuthorizationUrl({ provider: "" })).toThrow(
"Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.",
);
});
});

describe("when `screenHint` is provided with a non-`authkit` provider", () => {
it("throws a TypeError", () => {
expect(() =>
httpClient.getAuthorizationUrl({
provider: "google",
screenHint: "sign-in",
}),
).toThrow("'screenHint' is only supported for 'authkit' provider");
});
});
});

describe("getLogoutUrl", () => {
it("returns the logout URL with the given `baseUrl`", () => {
expect(httpClient.getLogoutUrl("123").toString()).toBe(
"https://api.workos.com/user_management/sessions/logout?session_id=123",
);
});
});
});
157 changes: 157 additions & 0 deletions src/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { RefreshError } from "./errors";
import {
AuthenticationResponseRaw,
GetAuthorizationUrlOptions,
} from "./interfaces";
import { deserializeAuthenticationResponse } from "./serializers";
import { toQueryString } from "./utils";

const DEFAULT_HOSTNAME = "api.workos.com";

export class HttpClient {
readonly #baseUrl: string;
readonly #clientId: string;

constructor({
clientId,
hostname = DEFAULT_HOSTNAME,
port,
https = true,
}: {
clientId: string;
hostname?: string;
port?: number;
https?: boolean;
}) {
this.#baseUrl = `${https ? "https" : "http"}://${hostname}${
port ? `:${port}` : ""
}`;
this.#clientId = clientId;
}

async authenticateWithRefreshToken({
refreshToken,
organizationId,
useCookie,
}: {
refreshToken: string | undefined;
organizationId?: string;
useCookie: boolean;
}) {
const response = await this.#post("/user_management/authenticate", {
useCookie,
body: {
client_id: this.#clientId,
grant_type: "refresh_token",
...(!useCookie && { refresh_token: refreshToken }),
organization_id: organizationId,
},
});

if (response.ok) {
const data = (await response.json()) as AuthenticationResponseRaw;
return deserializeAuthenticationResponse(data);
} else {
const error = (await response.json()) as any;
throw new RefreshError(error.error_description);
}
}

async authenticateWithCode({
code,
codeVerifier,
useCookie,
}: {
code: string;
codeVerifier: string;
useCookie: boolean;
}) {
const response = await this.#post("/user_management/authenticate", {
useCookie,
body: {
code,
client_id: this.#clientId,
grant_type: "authorization_code",
code_verifier: codeVerifier,
},
});

if (response.ok) {
const data = (await response.json()) as AuthenticationResponseRaw;
return deserializeAuthenticationResponse(data);
} else {
console.log("error", await response.json());
}
}

#post(
path: "/user_management/authenticate",
{ body, useCookie }: { body: Record<string, unknown>; useCookie: boolean },
) {
return fetch(new URL(path, this.#baseUrl), {
method: "POST",
...(useCookie && { credentials: "include" }),
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}

getAuthorizationUrl({
connectionId,
context,
domainHint,
loginHint,
organizationId,
provider = "authkit",
redirectUri,
state,
screenHint,
passwordResetToken,
invitationToken,
codeChallenge,
codeChallengeMethod,
}: GetAuthorizationUrlOptions) {
if (!provider && !connectionId && !organizationId) {
throw new TypeError(
`Incomplete arguments. Need to specify either a 'connectionId', 'organizationId', or 'provider'.`,
);
}

if (provider !== "authkit" && screenHint) {
throw new TypeError(
`'screenHint' is only supported for 'authkit' provider`,
);
}

const query = toQueryString({
connection_id: connectionId,
context,
organization_id: organizationId,
domain_hint: domainHint,
login_hint: loginHint,
provider,
client_id: this.#clientId,
redirect_uri: redirectUri,
response_type: "code",
state,
screen_hint: screenHint,
invitation_token: invitationToken,
password_reset_token: passwordResetToken,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
});

return `${this.#baseUrl}/user_management/authorize?${query}`;
}

getLogoutUrl(sessionId: string) {
const url = new URL("/user_management/sessions/logout", this.#baseUrl);

url.searchParams.set("session_id", sessionId);

return url;
}
}
1 change: 0 additions & 1 deletion src/interfaces/get-authorization-url-options.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export interface GetAuthorizationUrlOptions {
clientId: string;
connectionId?: string;
context?: string;
organizationId?: string;
Expand Down
37 changes: 0 additions & 37 deletions src/utils/authenticate-with-code.ts

This file was deleted.

Loading