diff --git a/bun.lockb b/bun.lockb index 232ef29..fe11d98 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9e4ccf7..c96d324 100644 --- a/package.json +++ b/package.json @@ -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": "hello+oss@sergiodxa.com", @@ -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" }, diff --git a/src/index.ts b/src/index.ts index c83adaa..b0c958c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,3 @@ -import { - AuthorizationCodeAccessTokenRequestContext, - AuthorizationCodeAuthorizationURL, - OAuth2RequestError, - RefreshRequestContext, - TokenResponseBody, - TokenRevocationRequestContext, - generateCodeVerifier, - generateState, - sendTokenRequest, - sendTokenRevocationRequest, -} from "@oslojs/oauth2"; import { AppLoadContext, SessionStorage, @@ -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"); @@ -103,7 +95,7 @@ export interface OAuth2StrategyVerifyParams< Profile extends OAuth2Profile, ExtraTokenParams extends Record = Record, > { - tokens: TokenResponseBody & ExtraTokenParams; + tokens: Token.Response.Body & ExtraTokenParams; profile: Profile; request: Request; context?: AppLoadContext; @@ -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, ); @@ -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); @@ -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); @@ -282,7 +274,7 @@ export class OAuth2Strategy< ); } - let tokens = await sendTokenRequest( + let tokens = await Token.Request.send( this.options.tokenEndpoint.toString(), context, { signal: request.signal }, @@ -334,7 +326,7 @@ export class OAuth2Strategy< } } - protected async userProfile(tokens: TokenResponseBody): Promise { + protected async userProfile(tokens: Token.Response.Body): Promise { return { provider: "oauth2" } as Profile; } @@ -349,7 +341,7 @@ export class OAuth2Strategy< */ protected authorizationParams( params: URLSearchParams, - request?: Request, + request: Request, ): URLSearchParams { return new URLSearchParams(params); } @@ -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( @@ -384,14 +376,14 @@ export class OAuth2Strategy< ); } - return sendTokenRequest( + return Token.Request.send( this.options.tokenEndpoint.toString(), context, { signal: options.signal }, ); } - public revokeToken( + public async revokeToken( token: string, options: { signal?: AbortSignal; @@ -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( @@ -420,8 +410,8 @@ export class OAuth2Strategy< ); } - return sendTokenRevocationRequest( - this.options.tokenRevocationEndpoint.toString(), + await Token.RevocationRequest.send( + this.options.tokenRevocationEndpoint, context, { signal: options.signal }, ); @@ -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; diff --git a/src/lib/authorization-code.ts b/src/lib/authorization-code.ts new file mode 100644 index 0000000..1751324 --- /dev/null +++ b/src/lib/authorization-code.ts @@ -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)); + const codeChallenge = encodeBase64urlNoPadding(codeChallengeBytes); + 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"); + } + } +} diff --git a/src/lib/generator.ts b/src/lib/generator.ts new file mode 100644 index 0000000..e4fd70a --- /dev/null +++ b/src/lib/generator.ts @@ -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); + } +} diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..1e0d56a --- /dev/null +++ b/src/lib/request.ts @@ -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["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; + } + } +} diff --git a/src/lib/token.ts b/src/lib/token.ts new file mode 100644 index 0000000..a4f6b51 --- /dev/null +++ b/src/lib/token.ts @@ -0,0 +1,150 @@ +/** + * 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 { OAuth2RequestResult, TokenRequestResult } from "@oslojs/oauth2"; +import { OAuth2Request } from "./request.js"; + +type URLConstructor = ConstructorParameters[0]; + +export namespace Token { + export namespace Response { + export interface Body { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + } + + export interface ErrorBody { + error: string; + error_description?: string; + } + } + + export namespace Request { + export class Context extends OAuth2Request.Context { + constructor(authorizationCode: string) { + super("POST"); + this.body.set("grant_type", "authorization_code"); + this.body.set("code", authorizationCode); + } + + public setCodeVerifier(codeVerifier: string): void { + this.body.set("code_verifier", codeVerifier); + } + + public setRedirectURI(redirectURI: string): void { + this.body.set("redirect_uri", redirectURI); + } + } + + export async function send>( + endpoint: URLConstructor, + context: OAuth2Request.Context, + options?: { signal?: AbortSignal }, + ): Promise { + let request = context.toRequest(endpoint); + let response = await fetch(request, { signal: options?.signal }); + let body = await response.json(); + + let result = new Result(body); + + if (result.hasErrorCode()) { + throw new OAuth2Request.Error( + result.errorCode(), + request, + context, + response.headers, + { + description: result.errorDescription(), + uri: result.errorURI(), + }, + ); + } + + return result.toJSON(); + } + + export class Result< + ExtraParams extends Record, + > extends TokenRequestResult { + toJSON(): Response.Body & ExtraParams { + return { + ...this.body, + access_token: this.accessToken(), + token_type: this.tokenType(), + expires_in: this.accessTokenExpiresInSeconds(), + scope: this.scopes().join(" "), + refresh_token: this.refreshToken(), + } as Response.Body & ExtraParams; + } + } + } + + export namespace RevocationRequest { + export class Context extends OAuth2Request.Context { + constructor(token: string) { + super("POST"); + this.body.set("token", token); + } + + public setTokenTypeHint( + tokenType: "access_token" | "refresh_token", + ): void { + if (tokenType === "access_token") { + this.body.set("token_type_hint", "access_token"); + } else if (tokenType === "refresh_token") { + this.body.set("token_type_hint", "refresh_token"); + } + } + } + + export async function send( + endpoint: URLConstructor, + context: OAuth2Request.Context, + options?: { signal?: AbortSignal }, + ) { + let request = context.toRequest(endpoint); + let response = await fetch(request, { signal: options?.signal }); + let body = await response.json(); + + let result = new OAuth2RequestResult(body); + + if (result.hasErrorCode()) { + throw new OAuth2Request.Error( + result.errorCode(), + request, + context, + response.headers, + { description: result.errorDescription(), uri: result.errorURI() }, + ); + } + } + } + + export namespace RefreshRequest { + export class Context extends OAuth2Request.Context { + constructor(refreshToken: string) { + super("POST"); + this.body.set("grant_type", "refresh_token"); + this.body.set("refresh_token", refreshToken); + } + + public addScopes(...scopes: string[]): void { + if (scopes.length < 1) { + return; + } + let scopeValue = scopes.join(" "); + const existingScopes = this.body.get("scope"); + if (existingScopes !== null) { + scopeValue = `${scopeValue} ${existingScopes}`; + } + this.body.set("scope", scopeValue); + } + } + } +} diff --git a/src/test/helpers.ts b/src/test/helpers.ts index e79c7cb..fb49b01 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -1,4 +1,4 @@ -export function isResponse(value: unknown): value is Response { +function isResponse(value: unknown): value is Response { return value instanceof Response; } diff --git a/src/test/mock.ts b/src/test/mock.ts index bd3b469..295556e 100644 --- a/src/test/mock.ts +++ b/src/test/mock.ts @@ -1,6 +1,5 @@ import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { TokenResponseBody } from "oslo/oauth2"; export const server = setupServer( http.post("https://example.app/token", async () => { @@ -10,6 +9,6 @@ export const server = setupServer( refresh_token: "mocked", scope: ["user:email", "user:profile"].join(" "), token_type: "Bearer", - } satisfies TokenResponseBody); + }); }), ); diff --git a/tsconfig.json b/tsconfig.json index 2a017f8..a03f335 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "outDir": "./build" }, "exclude": ["node_modules"], - "include": ["src/index.ts"] + "include": ["src/index.ts", "src/lib/**/*.ts"] }