Skip to content

Commit

Permalink
Add low-level auth lib
Browse files Browse the repository at this point in the history
  • Loading branch information
jaclarke committed Oct 18, 2023
1 parent d5fcf46 commit 69b1953
Show file tree
Hide file tree
Showing 11 changed files with 457 additions and 2 deletions.
7 changes: 7 additions & 0 deletions packages/auth/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testPathIgnorePatterns: ["./dist"],
globalSetup: "./test/globalSetup.ts",
globalTeardown: "./test/globalTeardown.ts",
};
21 changes: 21 additions & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@edgedb/auth",
"description": "",
"version": "0.0.1",
"author": "",
"main": "index.ts",
"scripts": {
"test": "jest --detectOpenHandles"
},
"devDependencies": {
"@types/node": "^20.8.4",
"edgedb": "^1.3.6",
"typescript": "5.0.4",
"@types/jest": "^29.5.2",
"jest": "29.5.0",
"ts-jest": "29.1.0"
},
"dependencies": {
"jwt-decode": "^3.1.2"
}
}
249 changes: 249 additions & 0 deletions packages/auth/src/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import jwtDecode from "jwt-decode";
import * as edgedb from "edgedb";
import { ResolvedConnectConfig } from "edgedb/dist/conUtils";

import * as pkce from "./pkce";

interface TokenData {
auth_token: string;
identity_id: string | null;
provider_token: string | null;
provider_refresh_token: string | null;
}

const builtinOAuthProviderNames = [
"builtin::oauth_apple",
"builtin::oauth_azure",
"builtin::oauth_github",
"builtin::oauth_google",
] as const;
type BuiltinOAuthProviderNames = (typeof builtinOAuthProviderNames)[number];

const builtinLocalProviderNames = ["builtin::local_emailpassword"] as const;
type BuiltinLocalProviderNames = (typeof builtinLocalProviderNames)[number];

export class Auth {
/** @internal */
public readonly baseUrl: string;

private constructor(private readonly client: edgedb.Client, baseUrl: string) {
this.baseUrl = baseUrl;
}

static async create(client: edgedb.Client) {
const connectConfig: ResolvedConnectConfig = (
await (client as any).pool._getNormalizedConnectConfig()
).connectionParams;

const [host, port] = connectConfig.address;
const baseUrl = `${
connectConfig.tlsSecurity === "insecure" ? "http" : "https"
}://${host}:${port}/db/${connectConfig.database}/ext/auth`;

return new this(client, baseUrl);
}

/** @internal */
public async _fetch(
path: string,
method: "get",
searchParams?: Record<string, string>
): Promise<unknown>;
public async _fetch(
path: string,
method: "post",
searchParams?: Record<string, string>,
body?: any
): Promise<unknown>;
public async _fetch(
path: string,
method: "get" | "post",
searchParams?: Record<string, string>,
body?: any
) {
const url = `${this.baseUrl}/${path}${
searchParams ? "?" + new URLSearchParams(searchParams).toString() : ""
}`;
const res = await fetch(url, {
method,
// verbose: true,
...(body != null
? {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}
: undefined),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (res.headers.get("content-type")?.startsWith("application/json")) {
return res.json();
}
return null;
}

createAuthSession() {
return new AuthSession(this);
}

getToken(code: string, verifier: string): Promise<TokenData> {
return this._fetch("token", "get", {
code,
verifier,
}) as Promise<TokenData>;
}

async signinWithEmailPassword(email: string, password: string) {
const { challenge, verifier } = pkce.createVerifierChallengePair();
const { code } = (await this._fetch("authenticate", "post", undefined, {
provider: "builtin::local_emailpassword",
challenge,
email,
password,
})) as { code: string };
return this.getToken(code, verifier);
}

async signupWithEmailPassword(email: string, password: string) {
const { challenge, verifier } = pkce.createVerifierChallengePair();
const { code } = (await this._fetch("register", "post", undefined, {
provider: "builtin::local_emailpassword",
challenge,
email,
password,
})) as { code: string };
return this.getToken(code, verifier);
}

async sendPasswordResetEmail(email: string, resetUrl: string) {
return this._fetch("send_reset_email", "post", undefined, {
provider: "builtin::local_emailpassword",
email,
reset_url: resetUrl,
}) as Promise<{ email_sent: string }>;
}

checkPasswordResetTokenValid(resetToken: string) {
const payload = jwtDecode(resetToken);
if (
typeof payload != "object" ||
payload == null ||
!("exp" in payload) ||
typeof payload.exp != "number"
) {
throw new Error("reset token does not contain valid expiry time");
}
return payload.exp < Date.now();
}

async resetPasswordWithResetToken(resetToken: string) {
return this._fetch("reset_password", "post", undefined, {
provider: "builtin::local_emailpassword",
reset_token: resetToken,
}) as Promise<TokenData>;
}

async getProvidersInfo() {
// TODO: cache this data somehow?
const providers = (await this.client.query(`
with module ext::auth
select cfg::Config.extensions[is AuthConfig].providers {
_typename := .__type__.name,
name,
[is OAuthProviderConfig].display_name,
}`)) as { _typename: string; name: string; display_name: string | null }[];
const emailPasswordProvider = providers.find(
(p) => p.name === "builtin::local_emailpassword"
);

return {
oauth: providers
.filter((p) => p.name.startsWith("builtin::oauth_"))
.map((p) => ({
name: p.name,
display_name: p.display_name!,
})),
emailPassword: emailPasswordProvider != null,
};
}
}

class AuthSession {
public readonly challenge: string;
public readonly verifier: string;

constructor(private auth: Auth) {
const { challenge, verifier } = pkce.createVerifierChallengePair();
this.challenge = challenge;
this.verifier = verifier;
}

getOAuthUrl(
providerName: BuiltinOAuthProviderNames,
redirectTo: string,
redirectToOnSignup?: string
) {
const params = new URLSearchParams({
provider_name: providerName,
challenge: this.challenge,
redirect_to: redirectTo,
});

if (redirectToOnSignup) {
params.append("redirect_to_on_signup", redirectToOnSignup);
}

return `${this.auth.baseUrl}/authorize?${params.toString()}`;
}

getEmailPasswordSigninFormActionUrl(
redirectTo: string,
redirectToOnFailure?: string
) {
const params = new URLSearchParams({
provider_name: "builtin::local_emailpassword",
challenge: this.challenge,
redirect_to: redirectTo,
});

if (redirectToOnFailure) {
params.append("redirect_on_failure", redirectToOnFailure);
}

return `${this.auth.baseUrl}/authenticate?${params.toString()}`;
}

getEmailPasswordSignupFormActionUrl(
redirectTo: string,
redirectToOnFailure?: string
) {
const params = new URLSearchParams({
provider_name: "builtin::local_emailpassword",
challenge: this.challenge,
redirect_to: redirectTo,
});

if (redirectToOnFailure) {
params.append("redirect_on_failure", redirectToOnFailure);
}

return `${this.auth.baseUrl}/register?${params.toString()}`;
}

getHostedUISigninUrl() {
const params = new URLSearchParams({
challenge: this.challenge,
});

return `${this.auth.baseUrl}/ui/sigin?${params.toString()}`;
}

getHostedUISignupUrl() {
const params = new URLSearchParams({
challenge: this.challenge,
});

return `${this.auth.baseUrl}/ui/signup?${params.toString()}`;
}
}
14 changes: 14 additions & 0 deletions packages/auth/src/pkce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import crypto from "node:crypto";

export function createVerifierChallengePair(): {
verifier: string;
challenge: string;
} {
const verifier = crypto.randomBytes(32).toString("hex");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");

return { verifier, challenge };
}
72 changes: 72 additions & 0 deletions packages/auth/test/core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import crypto from "node:crypto";
import { getClient } from "./testbase";

import { Auth } from "../src/core";

const SIGNING_KEY = crypto.randomBytes(32).toString("base64");

beforeAll(async () => {
const client = getClient();

try {
await client.execute(`
create extension pgcrypto;
create extension auth;
configure current database set
ext::auth::AuthConfig::auth_signing_key := '${SIGNING_KEY}';
configure current database set
ext::auth::AuthConfig::token_time_to_live := <duration>'24 hours';
configure current database set
ext::auth::SMTPConfig::sender := '[email protected]';
configure current database
insert ext::auth::EmailPasswordProviderConfig {};
`);

// wait for config to be applied
await new Promise((resolve) => setTimeout(resolve, 1000));
} finally {
client.close();
}
}, 20_000);

test("test password signup/signin flow", async () => {
const client = getClient({ tlsSecurity: "insecure" });
try {
const auth = await Auth.create(client);

const signupToken = await auth.signupWithEmailPassword(
"[email protected]",
"supersecretpassword"
);

expect(typeof signupToken.auth_token).toBe("string");
expect(typeof signupToken.identity_id).toBe("string");
expect(signupToken.provider_refresh_token).toBeNull();
expect(signupToken.provider_token).toBeNull();

await expect(
auth.signinWithEmailPassword("[email protected]", "wrongpassword")
).rejects.toThrow();

const signinToken = await auth.signinWithEmailPassword(
"[email protected]",
"supersecretpassword"
);

const identity = (await client.withGlobals({
"ext::auth::client_token": signinToken.auth_token,
}).querySingle(`
select assert_single(global ext::auth::ClientTokenIdentity {
*
})
`)) as any;

expect(identity.id).toBe(signinToken.identity_id);
} finally {
await client.close();
}
});
Loading

0 comments on commit 69b1953

Please sign in to comment.