Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade @oslojs/oauth2 dependency #108

Merged
merged 5 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
Binary file modified bun.lockb
Binary file not shown.
23 changes: 7 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,8 @@
"version": "2.1.0",
"description": "A strategy to use and implement OAuth2 framework for authentication with federated services like Google, Facebook, GitHub, etc.",
"license": "MIT",
"funding": [
"https://github.com/sponsors/sergiodxa"
],
"keywords": [
"remix",
"remix-auth",
"auth",
"authentication",
"strategy"
],
"funding": ["https://github.com/sponsors/sergiodxa"],
"keywords": ["remix", "remix-auth", "auth", "authentication", "strategy"],
"author": {
"name": "Sergio Xalambrí",
"email": "[email protected]",
Expand All @@ -31,23 +23,22 @@
"typecheck": "tsc --noEmit",
"quality": "biome check .",
"quality:fix": "biome check . --apply-unsafe",
"exports": "bun run ./scripts/exports.ts"
"exports": "bun run ./scripts/exports.ts",
"unused": "knip"
},
"sideEffects": false,
"type": "module",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=20.0.0"
},
"files": [
"build",
"package.json",
"README.md"
],
"files": ["build", "package.json", "README.md"],
"exports": {
".": "./build/index.js",
"./package.json": "./package.json"
},
"dependencies": {
"@oslojs/crypto": "^0.6.2",
"@oslojs/encoding": "^0.4.1",
"@oslojs/oauth2": "^0.5.0",
"debug": "^4.3.4"
},
Expand Down
56 changes: 23 additions & 33 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
import {
AuthorizationCodeAccessTokenRequestContext,
AuthorizationCodeAuthorizationURL,
OAuth2RequestError,
RefreshRequestContext,
TokenResponseBody,
TokenRevocationRequestContext,
generateCodeVerifier,
generateState,
sendTokenRequest,
sendTokenRevocationRequest,
} from "@oslojs/oauth2";
import {
AppLoadContext,
SessionStorage,
Expand All @@ -21,6 +9,10 @@ import {
Strategy,
StrategyVerifyCallback,
} from "remix-auth";
import { AuthorizationCode } from "./lib/authorization-code.js";
import { Generator } from "./lib/generator.js";
import { OAuth2Request } from "./lib/request.js";
import { Token } from "./lib/token.js";

let debug = createDebug("OAuth2Strategy");

Expand Down Expand Up @@ -103,7 +95,7 @@ export interface OAuth2StrategyVerifyParams<
Profile extends OAuth2Profile,
ExtraTokenParams extends Record<string, unknown> = Record<string, never>,
> {
tokens: TokenResponseBody & ExtraTokenParams;
tokens: Token.Response.Body & ExtraTokenParams;
profile: Profile;
request: Request;
context?: AppLoadContext;
Expand Down Expand Up @@ -168,17 +160,17 @@ export class OAuth2Strategy<
if (!stateUrl) {
debug("No state found in the URL, redirecting to authorization endpoint");

let state = generateState();
let state = Generator.state();
session.set(this.sessionStateKey, state);

debug("State", state);

let codeVerifier = generateCodeVerifier();
let codeVerifier = Generator.codeVerifier();
session.set(this.sessionCodeVerifierKey, codeVerifier);

debug("Code verifier", codeVerifier);

let authorizationURL = new AuthorizationCodeAuthorizationURL(
let authorizationURL = new AuthorizationCode.AuthorizationURL(
this.options.authorizationEndpoint.toString(),
this.options.clientId,
);
Expand All @@ -187,7 +179,7 @@ export class OAuth2Strategy<
authorizationURL.setState(state);

if (this.options.scopes)
authorizationURL.appendScopes(...this.options.scopes);
authorizationURL.addScopes(...this.options.scopes);

if (this.options.codeChallengeMethod === "S256") {
authorizationURL.setS256CodeChallenge(codeVerifier);
Expand Down Expand Up @@ -265,7 +257,7 @@ export class OAuth2Strategy<

try {
debug("Validating authorization code");
let context = new AuthorizationCodeAccessTokenRequestContext(code);
let context = new Token.Request.Context(code);

context.setRedirectURI(this.options.redirectURI.toString());
context.setCodeVerifier(codeVerifier);
Expand All @@ -282,7 +274,7 @@ export class OAuth2Strategy<
);
}

let tokens = await sendTokenRequest<TokenResponseBody & ExtraParams>(
let tokens = await Token.Request.send<ExtraParams>(
this.options.tokenEndpoint.toString(),
context,
{ signal: request.signal },
Expand Down Expand Up @@ -334,7 +326,7 @@ export class OAuth2Strategy<
}
}

protected async userProfile(tokens: TokenResponseBody): Promise<Profile> {
protected async userProfile(tokens: Token.Response.Body): Promise<Profile> {
return { provider: "oauth2" } as Profile;
}

Expand All @@ -349,7 +341,7 @@ export class OAuth2Strategy<
*/
protected authorizationParams(
params: URLSearchParams,
request?: Request,
request: Request,
): URLSearchParams {
return new URLSearchParams(params);
}
Expand All @@ -368,9 +360,9 @@ export class OAuth2Strategy<
) {
let scopes = options.scopes ?? this.options.scopes ?? [];

let context = new RefreshRequestContext(refreshToken);
let context = new Token.RefreshRequest.Context(refreshToken);

context.appendScopes(...scopes);
context.addScopes(...scopes);

if (this.options.authenticateWith === "http_basic_auth") {
context.authenticateWithHTTPBasicAuth(
Expand All @@ -384,14 +376,14 @@ export class OAuth2Strategy<
);
}

return sendTokenRequest<TokenResponseBody & ExtraParams>(
return Token.Request.send<ExtraParams>(
this.options.tokenEndpoint.toString(),
context,
{ signal: options.signal },
);
}

public revokeToken(
public async revokeToken(
token: string,
options: {
signal?: AbortSignal;
Expand All @@ -402,11 +394,9 @@ export class OAuth2Strategy<
throw new Error("Token revocation endpoint is not set");
}

let context = new TokenRevocationRequestContext(token);
let context = new Token.RevocationRequest.Context(token);

if (options.tokenType) {
context.setTokenTypeHint(options.tokenType);
}
if (options.tokenType) context.setTokenTypeHint(options.tokenType);

if (this.options.authenticateWith === "http_basic_auth") {
context.authenticateWithHTTPBasicAuth(
Expand All @@ -420,8 +410,8 @@ export class OAuth2Strategy<
);
}

return sendTokenRevocationRequest(
this.options.tokenRevocationEndpoint.toString(),
await Token.RevocationRequest.send(
this.options.tokenRevocationEndpoint,
context,
{ signal: options.signal },
);
Expand Down Expand Up @@ -449,5 +439,5 @@ export class OAuth2Error extends Error {
}
}

export { OAuth2RequestError };
export type { TokenResponseBody } from "@oslojs/oauth2";
export const OAuth2RequestError = OAuth2Request.Error;
export type TokenResponseBody = Token.Response.Body;
48 changes: 48 additions & 0 deletions src/lib/authorization-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* A lot of the code here was originally implemented by @pilcrowOnPaper for a
* previous version of `@oslojs/oauth2`, as Pilcrow decided to change the
* direction of the library to focus on response parsing, I decided to copy the
* old code and adapt it to the new structure of the library.
*/
import { sha256 } from "@oslojs/crypto/sha2";
import { encodeBase64urlNoPadding } from "@oslojs/encoding";

export namespace AuthorizationCode {
export class AuthorizationURL extends URL {
constructor(authorizationEndpoint: string, clientId: string) {
super(authorizationEndpoint);
this.searchParams.set("response_type", "code");
this.searchParams.set("client_id", clientId);
}

public setRedirectURI(redirectURI: string): void {
this.searchParams.set("redirect_uri", redirectURI);
}

public addScopes(...scopes: string[]): void {
if (scopes.length < 1) {
return;
}
let scopeValue = scopes.join(" ");
const existingScopes = this.searchParams.get("scope");
if (existingScopes !== null) scopeValue = ` ${existingScopes}`;
this.searchParams.set("scope", scopeValue);
}

public setState(state: string): void {
this.searchParams.set("state", state);
}

public setS256CodeChallenge(codeVerifier: string): void {
const codeChallengeBytes = sha256(new TextEncoder().encode(codeVerifier));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether this line could instead use the SubtleCrypto digest method. It would allow for the execution of native, possibly more optimised code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm - this surprises me:

I've made a fork of oslojs/crypto to check the compatibility and compare the performance using vitest bench.

To my surprise it appears that the Oslo implementation of sha256 may be faster than the SubtleCrypto one:
Screenshot 2024-08-06 at 11 37 09

It makes me wonder whether this is because of SubtleCrypto being async or something, and I may look into it further - but at least initially it is contrary to my intuition and claim.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm - maybe a weakness of my benchmark setup was to only ever hash 5 bytes.

The results become different when I modify the code to look like this:

import { bench, expect } from "vitest";
import { sha256 } from "./sha256.js";

const iterations = 10_000;

const byteCount = 4096;

bench(
	"SHA256: Oslo",
	() => {
		const randomValues = crypto.getRandomValues(new Uint8Array(byteCount));
		const hash = sha256(randomValues);

		expect(hash.length).toBe(32);
	},
	{ iterations }
);

bench(
	"SHA256: SubtleCrypto",
	async () => {
		const randomValues = crypto.getRandomValues(new Uint8Array(byteCount));
		const hash = await crypto.subtle.digest("SHA-256", randomValues);

		expect(hash.byteLength).toBe(32);
	},
	{ iterations }
);

Screenshot 2024-08-06 at 15 02 47

So there's certainly a question of input size at play here. At the same time I see a chance for remix-auth-oauth2 to have a dependency less.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into node it seems crypto.subtle.digest is provided by digest in lib/internal/crypto/webcrypto.js, which seems to schedule a HashJob defined in src/crypto/crypto_hash.h.

It makes sense to me that this would be faster than oslojs, but I see a possible question as to the input size we're expecting, too. Given that Generator.codeVerifier only provides 32 bytes of randomness we may generally be dealing with shorter sequences of bytes.

For 32 bytes of input to the hash function I've still got Oslo ahead in hash timings. Generally hashing seems to be so fast here that it should barely make a difference - and that it shouldn't matter much whether it's done sync or async.

I think I'd have a slight preference for SubtleCrypto on the grounds of using fewer dependencies and using something that's less likely to cause issues if used for bigger inputs while still being okay for smaller input sizes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made a suggestion to drop @oslojs/crypto here: #109

const codeChallenge = encodeBase64urlNoPadding(codeChallengeBytes);
Copy link
Contributor

@runjak runjak Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Buffer.from(codeChallengeBytes).toString(base64url) could do the same, with the docs saying that it also adds no padding. It, too could be a chance for native code over rewritten encoding.

My understanding is that remix-auth-oauth2 may shy away from Buffer here for interoperability with other runtimes? If I'm reading right Bun also has Buffer around, but deno seems a bit different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made a fork of oslojs/encoding to check the compatibility between encodeBase64urlNoPadding and using Buffer directly and to compare the performance using vitest bench.

Executing this locally with both node 20.7.0 as well as bun 1.1.21 gave these results:
Screenshot 2024-08-06 at 14 24 58

It seems that using Buffer could indeed be faster or at least be of similar speed. At the same time this could allow to remove the dependency on @oslojs/encoding.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect here becomes even more pronounced when I increase the string sizes:

import { bench, expect } from "vitest";
import { encodeBase64urlNoPadding } from "./base64.js";

const iterations = 1_000;

const from = 4_000;
const to = from + 100;

bench(
	"encodeBase64urlNoPadding: Oslo",
	() => {
		for (let i = from; i <= to; i++) {
			const bytes = new Uint8Array(i);
			crypto.getRandomValues(bytes);

			const oslo = encodeBase64urlNoPadding(bytes);

			expect(oslo).not.toEqual("");
		}
	},
	{ iterations }
);

bench(
	"encodeBase64urlNoPadding: Node",
	() => {
		for (let i = from; i <= to; i++) {
			const bytes = new Uint8Array(i);
			crypto.getRandomValues(bytes);

			const node = Buffer.from(bytes).toString("base64url");

			expect(node).not.toEqual("");
		}
	},
	{ iterations }
);

Screenshot 2024-08-06 at 14 56 59

I've looked into the code of node a bit and found that indeed the buffer encoding ends up in native code and from what I could gather leads somewhere to encode_base64 in deps/simdutf/simdutf.cpp, which seems to make use of SIMD instructions rather heavily.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that remix itself makes use of Buffers in some places. An example would be packages/remix-node/sessions/fileStorage.ts, where createFileSessionStorage uses Buffer.from.

Personally I'd be a fan of ditching the @oslojs/encoding dependency in favour of more node and bun specific APIs, but I feel this would be your call to make @sergiodxa :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related suggestion: #110

Copy link
Owner Author

@sergiodxa sergiodxa Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Buffer doesn't exists on Cloudflare, without node_compat, in general Cloudflare is the main reason to avoid these things as it has a more limited runtime APIs

Remix's @remix-run/node package is only for Node, in @remix-run/cloudflare they don't use Buffer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see - this wasn't on my radar - thanks 🙂

this.searchParams.set("code_challenge", codeChallenge);
this.searchParams.set("code_challenge_method", "S256");
}

public setPlainCodeChallenge(codeVerifier: string): void {
this.searchParams.set("code_challenge", codeVerifier);
this.searchParams.set("code_challenge_method", "plain");
}
}
}
21 changes: 21 additions & 0 deletions src/lib/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* A lot of the code here was originally implemented by @pilcrowOnPaper for a
* previous version of `@oslojs/oauth2`, as Pilcrow decided to change the
* direction of the library to focus on response parsing, I decided to copy the
* old code and adapt it to the new structure of the library.
*/
import { encodeBase64urlNoPadding } from "@oslojs/encoding";

export namespace Generator {
export function codeVerifier(): string {
const randomValues = new Uint8Array(32);
crypto.getRandomValues(randomValues);
return encodeBase64urlNoPadding(randomValues);
}

export function state(): string {
const randomValues = new Uint8Array(32);
crypto.getRandomValues(randomValues);
return encodeBase64urlNoPadding(randomValues);
}
}
76 changes: 76 additions & 0 deletions src/lib/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* A lot of the code here was originally implemented by @pilcrowOnPaper for a
* previous version of `@oslojs/oauth2`, as Pilcrow decided to change the
* direction of the library to focus on response parsing, I decided to copy the
* old code and adapt it to the new structure of the library.
*/
import { encodeBase64 } from "@oslojs/encoding";

export namespace OAuth2Request {
export abstract class Context {
public method: string;
public body = new URLSearchParams();
public headers = new Headers();

constructor(method: string) {
this.method = method;
this.headers.set("Content-Type", "application/x-www-form-urlencoded");
this.headers.set("Accept", "application/json");
this.headers.set("User-Agent", "oslo");
}

public setClientId(clientId: string): void {
this.body.set("client_id", clientId);
}

public authenticateWithRequestBody(
clientId: string,
clientSecret: string,
): void {
this.setClientId(clientId);
this.body.set("client_secret", clientSecret);
}

public authenticateWithHTTPBasicAuth(
clientId: string,
clientSecret: string,
): void {
const authorizationHeader = `Basic ${encodeBase64(
new TextEncoder().encode(`${clientId}:${clientSecret}`),
)}`;
this.headers.set("Authorization", authorizationHeader);
}

toRequest(url: ConstructorParameters<URL>["0"]) {
return new Request(url, {
method: this.method,
body: this.body,
headers: this.headers,
});
}
}

// biome-ignore lint/suspicious/noShadowRestrictedNames: It's namespaced
export class Error extends globalThis.Error {
public request: Request;
public context: OAuth2Request.Context;
public description: string | null;
public uri: string | null;
public responseHeaders: Headers;

constructor(
message: string,
request: Request,
context: OAuth2Request.Context,
responseHeaders: Headers,
options?: { description?: string; uri?: string },
) {
super(message);
this.request = request;
this.context = context;
this.responseHeaders = responseHeaders;
this.description = options?.description ?? null;
this.uri = options?.uri ?? null;
}
}
}
Loading