├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── deno.json ├── deno.lock ├── examples ├── http.ts └── oak.ts ├── mod.ts └── src ├── authorization_code_grant.ts ├── authorization_code_grant_test.ts ├── client_credentials_grant.ts ├── client_credentials_grant_test.ts ├── errors.ts ├── errors_test.ts ├── grant_base.ts ├── grant_base_test.ts ├── implicit_grant.ts ├── implicit_grant_test.ts ├── oauth2_client.ts ├── oauth2_client_test.ts ├── pkce.ts ├── pkce_test.ts ├── refresh_token_grant.ts ├── refresh_token_grant_test.ts ├── resource_owner_password_credentials.ts ├── resource_owner_password_credentials_test.ts ├── test_utils.ts └── types.ts /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | id-token: write # The OIDC ID token is used for authentication with JSR. 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npx jsr publish 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow will install Deno and run tests across stable and nightly builds on Windows, Ubuntu and macOS. 7 | # For more information see: https://github.com/denolib/setup-deno 8 | 9 | name: Tests 10 | 11 | on: [push, pull_request] 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} # runs a test on Ubuntu, Windows and macOS 16 | 17 | strategy: 18 | matrix: 19 | deno: ["latest", "canary"] 20 | os: [macOS-latest, windows-latest, ubuntu-latest] 21 | 22 | steps: 23 | - name: Setup repo 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Deno 27 | uses: denoland/setup-deno@v2 28 | with: 29 | deno-version: ${{ matrix.deno }} # tests across multiple Deno versions 30 | 31 | - name: Publish (dry run) 32 | run: deno publish --dry-run 33 | 34 | - name: Check types 35 | run: deno task check:types 36 | 37 | - name: Run Tests 38 | run: deno task test --coverage=cov_profile 39 | 40 | - name: Print Coverage 41 | run: deno coverage cov_profile 42 | 43 | lint: 44 | strategy: 45 | matrix: 46 | deno: ["latest", "canary"] 47 | os: [ubuntu-latest] 48 | runs-on: ${{ matrix.os }} 49 | steps: 50 | - name: Setup repo 51 | uses: actions/checkout@v4 52 | 53 | - name: Setup Deno 54 | uses: denoland/setup-deno@v2 55 | with: 56 | deno-version: ${{ matrix.deno }} 57 | 58 | - name: Run lint 59 | run: deno lint 60 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "deno.lint": true, 5 | "deno.import_intellisense_origins": { 6 | "https://deno.land": true 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "denoland.vscode-deno" 10 | }, 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "denoland.vscode-deno" 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonas Auer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth2 Client for Deno 2 | 3 | [![JSR](https://jsr.io/badges/@cmd-johnson/oauth2-client)](https://jsr.io/@cmd-johnson/oauth2-client) 4 | ![Tests](https://github.com/cmd-johnson/deno-oauth2-client/workflows/Tests/badge.svg) 5 | [![deno doc](https://doc.deno.land/badge.svg)](https://jsr.io/@cmd-johnson/oauth2-client/doc) 6 | 7 | > [!IMPORTANT] 8 | > This package will no longer be published to https://deno.land/x or support 9 | > `http:` imports starting from version 2.0.0. Instead, future versions will be 10 | > published to [JSR](https://jsr.io/) and require `jsr:` imports. See the 11 | > [migration guide](#v1---v2) for instructions on how to migrate. 12 | 13 | Minimalistic OAuth 2.0 client for Deno. Inspired by 14 | [js-client-oauth2](https://github.com/mulesoft/js-client-oauth2/). 15 | 16 | This module tries not to make assumptions on your use-cases. As such, it 17 | 18 | - has no external dependencies outside of Deno's standard library 19 | - can be used with Deno's [http module](https://deno.land/std@0.71.0/http) or 20 | any other library for handling http requests, like 21 | [oak](https://deno.land/x/oak) 22 | - only implements OAuth 2.0 grants, letting you take care of storing and 23 | retrieving sessions, managing state parameters, etc. 24 | 25 | Currently supported OAuth 2.0 grants: 26 | 27 | - [Authorization Code Grant (for clients with and without client secrets)](https://www.rfc-editor.org/rfc/rfc6749#section-4.1) 28 | - Out of the box support for 29 | [Proof Key for Code Exchange (PKCE)](https://www.rfc-editor.org/rfc/rfc7636) 30 | - [Implicit Grant](https://www.rfc-editor.org/rfc/rfc6749#section-4.2) 31 | - [Resource Owner Password Credentials Grant](https://www.rfc-editor.org/rfc/rfc6749#section-4.3) 32 | - [Client Credentials Grant](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) 33 | - [Refresh Tokens](https://www.rfc-editor.org/rfc/rfc6749#section-6) 34 | 35 | ## Usage 36 | 37 | ### GitHub API example using [oak](https://jsr.io/@oak/oak) 38 | 39 | ```ts ignore 40 | import { Application } from "jsr:@oak/oak@^17.1.3/application"; 41 | import { Router } from "jsr:@oak/oak@^17.1.3/router"; 42 | import { 43 | MemoryStore, 44 | Session, 45 | } from "https://deno.land/x/oak_sessions@v9.0.0/mod.ts"; 46 | import { OAuth2Client } from "jsr:@cmd-johnson/oauth2-client@^2.0.0"; 47 | 48 | const oauth2Client = new OAuth2Client({ 49 | clientId: Deno.env.get("CLIENT_ID")!, 50 | clientSecret: Deno.env.get("CLIENT_SECRET")!, 51 | authorizationEndpointUri: "https://github.com/login/oauth/authorize", 52 | tokenUri: "https://github.com/login/oauth/access_token", 53 | redirectUri: "http://localhost:8000/oauth2/callback", 54 | defaults: { 55 | scope: "read:user", 56 | }, 57 | }); 58 | 59 | type AppState = { 60 | session: Session; 61 | }; 62 | 63 | const router = new Router(); 64 | router.get("/login", async (ctx) => { 65 | // Construct the URL for the authorization redirect and get a PKCE codeVerifier 66 | const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri(); 67 | 68 | // Store both the state and codeVerifier in the user session 69 | ctx.state.session.flash("codeVerifier", codeVerifier); 70 | 71 | // Redirect the user to the authorization endpoint 72 | ctx.response.redirect(uri); 73 | }); 74 | router.get("/oauth2/callback", async (ctx) => { 75 | // Make sure the codeVerifier is present for the user's session 76 | const codeVerifier = ctx.state.session.get("codeVerifier"); 77 | if (typeof codeVerifier !== "string") { 78 | throw new Error("invalid codeVerifier"); 79 | } 80 | 81 | // Exchange the authorization code for an access token 82 | const tokens = await oauth2Client.code.getToken(ctx.request.url, { 83 | codeVerifier, 84 | }); 85 | 86 | // Use the access token to make an authenticated API request 87 | const userResponse = await fetch("https://api.github.com/user", { 88 | headers: { 89 | Authorization: `Bearer ${tokens.accessToken}`, 90 | }, 91 | }); 92 | const { login } = await userResponse.json(); 93 | 94 | ctx.response.body = `Hello, ${login}!`; 95 | }); 96 | 97 | const app = new Application(); 98 | app.use(Session.initMiddleware()); 99 | app.use(router.allowedMethods(), router.routes()); 100 | 101 | await app.listen({ port: 8000 }); 102 | ``` 103 | 104 | ### More Examples 105 | 106 | For more examples, check out the examples directory. 107 | 108 | ## Migration 109 | 110 | ### `v0.*.*` -> `v1.*.*` 111 | 112 | With `v1.0.0`: 113 | 114 | - we introduced PKCE by default for the Authorization Code Grant 115 | - enabled `stateValidator` callbacks to return a Promise, to allow for e.g. 116 | accessing a database 117 | - cleaned up interface names to prevent name clashes between e.g. the 118 | `AuthorizationCodeGrant` and `ImplicitGrant` option objects. 119 | 120 | #### `AuthorizationCodeGrant` 121 | 122 | - The `GetUriOptions` interface was renamed to `AuthorizationUriOptions` 123 | - `getAuthorizationUri(...)` now always returns a `Promise<{ uri: URL }>` 124 | instead of a plain `URL`. 125 | - when using PKCE (which is now the default), `getAuthorizationUri(...)` 126 | returns an object containing both an URI and the `codeVerifier` that you'll 127 | have to pass to the `getToken(...)` call inside the OAuth 2.0 redirection 128 | URI handler. Check out the examples on how to achieve that by using session 129 | cookies. 130 | - while you should always use PKCE if possible, there are still OAuth 2.0 131 | servers that don't support it. To opt out of PKCE, pass 132 | `{ disablePkce: true }` to `getAuthorizationUri`. 133 | 134 | #### `ClientCredentialsGrant` 135 | 136 | - The `GetClientCredentialsTokenOptions` interface was renamed to 137 | `ClientCredentialsTokenOptions` 138 | 139 | #### `ImplicitGrant` 140 | 141 | - The `GetUriOptions` interface was renamed to `ImplicitUriOptions` 142 | - The `GetTokenOptions` interface was renamed to `ImplicitTokenOptions` 143 | 144 | #### `ResourceOwnerPasswordCredentialsGrant` 145 | 146 | - The `GetROPCTokenOptions` interface was renamed to 147 | `ResourceOwnerPasswordCredentialsTokenOptions` 148 | 149 | #### `RefreshTokenGrant` 150 | 151 | - No changes necessary 152 | 153 | ### `v1.*.*` -> `v2.*.*` 154 | 155 | This package is now published to [JSR](https://jsr.io/) and no longer to 156 | https://deno.land/x. To migrate, replace HTTP imports with the root 157 | [`jsr:` import](https://jsr.io/docs/native-imports). 158 | 159 | ```diff 160 | - import { OAuth2Client } from "https://deno.land/x/oauth2_client/mod.ts"; 161 | + import { OAuth2Client } from "jsr:@cmd-johnson/oauth2-client"; 162 | 163 | // ... 164 | ``` 165 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cmd-johnson/oauth2-client", 3 | "version": "2.0.0", 4 | "imports": { 5 | "@oak/oak": "jsr:@oak/oak@^17.1.3", 6 | "@std/assert": "jsr:@std/assert@^1.0.7", 7 | "@std/encoding": "jsr:@std/encoding@^1.0.5", 8 | "@std/http": "jsr:@std/http@^1.0.9", 9 | "@std/testing": "jsr:@std/testing@^1.0.4", 10 | "https://deno.land/x/oauth2_client/": "./" 11 | }, 12 | "tasks": { 13 | "check:types": "deno check **/*.ts", 14 | "test": "deno test --parallel --trace-leaks --doc" 15 | }, 16 | "exports": "./mod.ts" 17 | } 18 | -------------------------------------------------------------------------------- /examples/http.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Cookie, 3 | deleteCookie, 4 | getCookies, 5 | setCookie, 6 | } from "jsr:@std/http@^1.0.10/cookie"; 7 | import { OAuth2Client } from "jsr:@cmd-johnson/oauth2-client@^2.0.0"; 8 | 9 | const oauth2Client = new OAuth2Client({ 10 | clientId: Deno.env.get("CLIENT_ID")!, 11 | clientSecret: Deno.env.get("CLIENT_SECRET")!, 12 | authorizationEndpointUri: "https://github.com/login/oauth/authorize", 13 | tokenUri: "https://github.com/login/oauth/access_token", 14 | redirectUri: "http://localhost:8000/oauth2/callback", 15 | defaults: { 16 | scope: "read:user", 17 | }, 18 | }); 19 | 20 | /** This is where we'll store our state and PKCE codeVerifiers */ 21 | const loginStates = new Map(); 22 | /** The name we'll use for the session cookie */ 23 | const cookieName = "session"; 24 | 25 | /** Handles incoming HTTP requests */ 26 | function handler(req: Request): Promise | Response { 27 | const url = new URL(req.url); 28 | const path = url.pathname; 29 | 30 | switch (path) { 31 | case "/login": 32 | return redirectToAuthEndpoint(); 33 | case "/oauth2/callback": 34 | return handleCallback(req); 35 | default: 36 | return new Response("Not Found", { status: 404 }); 37 | } 38 | } 39 | 40 | async function redirectToAuthEndpoint(): Promise { 41 | // Generate a random state 42 | const state = crypto.randomUUID(); 43 | 44 | const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri({ 45 | state, 46 | }); 47 | 48 | // Associate the state and PKCE codeVerifier with a session cookie 49 | const sessionId = crypto.randomUUID(); 50 | loginStates.set(sessionId, { state, codeVerifier }); 51 | const sessionCookie: Cookie = { 52 | name: cookieName, 53 | value: sessionId, 54 | httpOnly: true, 55 | sameSite: "Lax", 56 | }; 57 | const headers = new Headers({ Location: uri.toString() }); 58 | setCookie(headers, sessionCookie); 59 | 60 | // Redirect to the authorization endpoint 61 | return new Response(null, { status: 302, headers }); 62 | } 63 | 64 | async function handleCallback(req: Request): Promise { 65 | // Load the state and PKCE codeVerifier associated with the session 66 | const sessionCookie = getCookies(req.headers)[cookieName]; 67 | const loginState = sessionCookie && loginStates.get(sessionCookie); 68 | if (!loginState) { 69 | throw new Error("invalid session"); 70 | } 71 | loginStates.delete(sessionCookie); 72 | 73 | // Exchange the authorization code for an access token 74 | const tokens = await oauth2Client.code.getToken(req.url, loginState); 75 | 76 | // Use the access token to make an authenticated API request 77 | const userResponse = await fetch("https://api.github.com/user", { 78 | headers: { 79 | Authorization: `Bearer ${tokens.accessToken}`, 80 | }, 81 | }); 82 | const { login } = await userResponse.json(); 83 | 84 | // Clear the session cookie since we don't need it anymore 85 | const headers = new Headers(); 86 | deleteCookie(headers, cookieName); 87 | return new Response(`Hello, ${login}!`); 88 | } 89 | 90 | // Start the app 91 | Deno.serve({ port: 8000 }, handler); 92 | -------------------------------------------------------------------------------- /examples/oak.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "jsr:@oak/oak@^17.1.3/application"; 2 | import { Router } from "jsr:@oak/oak@^17.1.3/router"; 3 | import { 4 | MemoryStore, 5 | Session, 6 | } from "https://deno.land/x/oak_sessions@v9.0.0/mod.ts"; 7 | import { OAuth2Client } from "jsr:@cmd-johnson/oauth2-client@^2.0.0"; 8 | 9 | const oauth2Client = new OAuth2Client({ 10 | clientId: Deno.env.get("CLIENT_ID")!, 11 | clientSecret: Deno.env.get("CLIENT_SECRET")!, 12 | authorizationEndpointUri: "https://github.com/login/oauth/authorize", 13 | tokenUri: "https://github.com/login/oauth/access_token", 14 | redirectUri: "http://localhost:8000/oauth2/callback", 15 | defaults: { 16 | scope: "read:user", 17 | }, 18 | }); 19 | 20 | type AppState = { 21 | session: Session; 22 | }; 23 | 24 | const router = new Router(); 25 | router.get("/login", async (ctx) => { 26 | // Generate a random state for this login event 27 | const state = crypto.randomUUID(); 28 | 29 | // Construct the URL for the authorization redirect and get a PKCE codeVerifier 30 | const { uri, codeVerifier } = await oauth2Client.code.getAuthorizationUri({ 31 | state, 32 | }); 33 | 34 | // Store both the state and codeVerifier in the user session 35 | ctx.state.session.flash("state", state); 36 | ctx.state.session.flash("codeVerifier", codeVerifier); 37 | 38 | // Redirect the user to the authorization endpoint 39 | ctx.response.redirect(uri); 40 | }); 41 | router.get("/oauth2/callback", async (ctx) => { 42 | // Make sure both a state and codeVerifier are present for the user's session 43 | const state = ctx.state.session.get("state"); 44 | if (typeof state !== "string") { 45 | throw new Error("invalid state"); 46 | } 47 | 48 | const codeVerifier = ctx.state.session.get("codeVerifier"); 49 | if (typeof codeVerifier !== "string") { 50 | throw new Error("invalid codeVerifier"); 51 | } 52 | 53 | // Exchange the authorization code for an access token 54 | const tokens = await oauth2Client.code.getToken(ctx.request.url, { 55 | state, 56 | codeVerifier, 57 | }); 58 | 59 | // Use the access token to make an authenticated API request 60 | const userResponse = await fetch("https://api.github.com/user", { 61 | headers: { 62 | Authorization: `Bearer ${tokens.accessToken}`, 63 | }, 64 | }); 65 | const { login } = await userResponse.json(); 66 | 67 | ctx.response.body = `Hello, ${login}!`; 68 | }); 69 | 70 | const app = new Application(); 71 | 72 | // Add a key for signing cookies 73 | app.keys = ["super-secret-key"]; 74 | 75 | // Set up the session middleware 76 | const sessionStore = new MemoryStore(); 77 | app.use(Session.initMiddleware(sessionStore, { 78 | cookieSetOptions: { 79 | httpOnly: true, 80 | sameSite: "lax", 81 | // Enable for when running outside of localhost 82 | // secure: true, 83 | signed: true, 84 | }, 85 | cookieGetOptions: { 86 | signed: true, 87 | }, 88 | expireAfterSeconds: 60 * 10, 89 | })); 90 | 91 | // Mount the router 92 | app.use(router.allowedMethods(), router.routes()); 93 | 94 | // Start the app 95 | const port = 8000; 96 | app.addEventListener("listen", () => { 97 | console.log( 98 | `App listening on port ${port}. Navigate to http://localhost:${port}/login to log in!`, 99 | ); 100 | }); 101 | await app.listen({ port }); 102 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export type { RequestOptions, Tokens } from "./src/types.ts"; 2 | 3 | export { 4 | AuthorizationResponseError, 5 | MissingClientSecretError, 6 | OAuth2ResponseError, 7 | TokenResponseError, 8 | } from "./src/errors.ts"; 9 | 10 | export { OAuth2Client } from "./src/oauth2_client.ts"; 11 | export type { OAuth2ClientConfig } from "./src/oauth2_client.ts"; 12 | 13 | export type { 14 | AuthorizationCodeGrant, 15 | AuthorizationCodeTokenOptions, 16 | AuthorizationUri, 17 | AuthorizationUriOptions, 18 | AuthorizationUriWithoutVerifier, 19 | AuthorizationUriWithVerifier, 20 | } from "./src/authorization_code_grant.ts"; 21 | export type { 22 | ClientCredentialsGrant, 23 | ClientCredentialsTokenOptions, 24 | } from "./src/client_credentials_grant.ts"; 25 | export type { 26 | ImplicitGrant, 27 | ImplicitTokenOptions, 28 | ImplicitUriOptions, 29 | } from "./src/implicit_grant.ts"; 30 | export type { 31 | ResourceOwnerPasswordCredentialsGrant, 32 | ResourceOwnerPasswordCredentialsTokenOptions, 33 | } from "./src/resource_owner_password_credentials.ts"; 34 | export type { 35 | RefreshTokenGrant, 36 | RefreshTokenOptions, 37 | } from "./src/refresh_token_grant.ts"; 38 | -------------------------------------------------------------------------------- /src/authorization_code_grant.ts: -------------------------------------------------------------------------------- 1 | import type { OAuth2Client } from "./oauth2_client.ts"; 2 | import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts"; 3 | import type { RequestOptions, Tokens } from "./types.ts"; 4 | import { OAuth2GrantBase } from "./grant_base.ts"; 5 | import { createPkceChallenge } from "./pkce.ts"; 6 | 7 | interface AuthorizationUriOptionsWithPKCE { 8 | /** 9 | * State parameter to send along with the authorization request. 10 | * 11 | * see https://tools.ietf.org/html/rfc6749#section-4.1.1 12 | */ 13 | state?: string; 14 | /** 15 | * Scopes to request with the authorization request. 16 | * 17 | * If an array is passed, it is concatenated using spaces as per 18 | * https://tools.ietf.org/html/rfc6749#section-3.3 19 | */ 20 | scope?: string | string[]; 21 | /** Set to true to opt out of using PKCE */ 22 | disablePkce?: false; 23 | } 24 | 25 | type AuthorizationUriOptionsWithoutPKCE = 26 | & Omit 27 | & { disablePkce: true }; 28 | 29 | export type AuthorizationUriOptions = 30 | | AuthorizationUriOptionsWithPKCE 31 | | AuthorizationUriOptionsWithoutPKCE; 32 | 33 | export interface AuthorizationUriWithoutVerifier { 34 | uri: URL; 35 | } 36 | export interface AuthorizationUriWithVerifier { 37 | uri: URL; 38 | codeVerifier: string; 39 | } 40 | 41 | export type AuthorizationUri = 42 | | AuthorizationUriWithVerifier 43 | | AuthorizationUriWithoutVerifier; 44 | 45 | export interface AuthorizationCodeTokenOptions { 46 | /** 47 | * The state parameter expected to be returned by the authorization response. 48 | * 49 | * Usually you'd store the state you sent with the authorization request in the 50 | * user's session so you can pass it here. 51 | * If it could be one of many states or you want to run some custom verification 52 | * logic, use the `stateValidator` parameter instead. 53 | */ 54 | state?: string; 55 | /** 56 | * The state validator used to verify that the received state is valid. 57 | * 58 | * The option object's state value is ignored when a stateValidator is passed. 59 | */ 60 | stateValidator?: (state: string | null) => Promise | boolean; 61 | /** 62 | * When using PKCE, the code verifier that you got by calling getAuthorizationUri 63 | */ 64 | codeVerifier?: string; 65 | /** Request options used when making the access token request. */ 66 | requestOptions?: RequestOptions; 67 | } 68 | 69 | /** 70 | * Implements the OAuth 2.0 authorization code grant. 71 | * 72 | * See https://tools.ietf.org/html/rfc6749#section-4.1 73 | */ 74 | export class AuthorizationCodeGrant extends OAuth2GrantBase { 75 | constructor(client: OAuth2Client) { 76 | super(client); 77 | } 78 | 79 | /** 80 | * Builds a URI you can redirect a user to to make the authorization request. 81 | * 82 | * By default, {@link https://www.rfc-editor.org/rfc/rfc7636 PKCE} will be used. 83 | * You can opt out of PKCE by passing `{ disablePkce: true }` in the options. 84 | * 85 | * When using PKCE it is your responsibility to store the returned `codeVerifier` 86 | * and associate it with the user's session just like with the `state` parameter. 87 | * You have to pass it to the `getToken()` request when you receive the 88 | * authorization callback or the token request will fail. 89 | */ 90 | public getAuthorizationUri( 91 | options?: AuthorizationUriOptionsWithPKCE, 92 | ): Promise; 93 | public getAuthorizationUri( 94 | options: AuthorizationUriOptionsWithoutPKCE, 95 | ): Promise; 96 | public async getAuthorizationUri( 97 | options: AuthorizationUriOptions = {}, 98 | ): Promise { 99 | const params = new URLSearchParams(); 100 | params.set("response_type", "code"); 101 | params.set("client_id", this.client.config.clientId); 102 | if (typeof this.client.config.redirectUri === "string") { 103 | params.set("redirect_uri", this.client.config.redirectUri); 104 | } 105 | const scope = options.scope ?? this.client.config.defaults?.scope; 106 | if (scope) { 107 | params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); 108 | } 109 | if (options.state) { 110 | params.set("state", options.state); 111 | } 112 | 113 | if (options.disablePkce === true) { 114 | return { 115 | uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri), 116 | }; 117 | } 118 | 119 | const challenge = await createPkceChallenge(); 120 | params.set("code_challenge", challenge.codeChallenge); 121 | params.set("code_challenge_method", challenge.codeChallengeMethod); 122 | return { 123 | uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri), 124 | codeVerifier: challenge.codeVerifier, 125 | }; 126 | } 127 | 128 | /** 129 | * Parses the authorization response request tokens from the authorization server. 130 | * 131 | * Usually you'd want to call this method in the function that handles the user's request to your configured redirectUri. 132 | * @param authResponseUri The complete URI the user got redirected to by the authorization server after making the authorization request. 133 | * Must include all received URL parameters. 134 | */ 135 | public async getToken( 136 | authResponseUri: string | URL, 137 | options: AuthorizationCodeTokenOptions = {}, 138 | ): Promise { 139 | const validated = await this.validateAuthorizationResponse( 140 | this.toUrl(authResponseUri), 141 | options, 142 | ); 143 | 144 | const request = this.buildAccessTokenRequest( 145 | validated.code, 146 | options.codeVerifier, 147 | options.requestOptions, 148 | ); 149 | 150 | const accessTokenResponse = await fetch(request); 151 | 152 | return this.parseTokenResponse(accessTokenResponse); 153 | } 154 | 155 | private async validateAuthorizationResponse( 156 | url: URL, 157 | options: AuthorizationCodeTokenOptions, 158 | ): Promise<{ code: string; state?: string }> { 159 | if (typeof this.client.config.redirectUri === "string") { 160 | const expectedUrl = new URL(this.client.config.redirectUri); 161 | 162 | if ( 163 | typeof url.pathname === "string" && 164 | url.pathname !== expectedUrl.pathname 165 | ) { 166 | throw new AuthorizationResponseError( 167 | `Redirect path should match configured path, but got: ${url.pathname}`, 168 | ); 169 | } 170 | } 171 | 172 | if (!url.search || !url.search.substr(1)) { 173 | throw new AuthorizationResponseError( 174 | `URI does not contain callback parameters: ${url}`, 175 | ); 176 | } 177 | 178 | const params = new URLSearchParams(url.search || ""); 179 | 180 | if (params.get("error") !== null) { 181 | throw OAuth2ResponseError.fromURLSearchParams(params); 182 | } 183 | 184 | const code = params.get("code") || ""; 185 | if (!code) { 186 | throw new AuthorizationResponseError( 187 | "Missing code, unable to request token", 188 | ); 189 | } 190 | 191 | const state = params.get("state"); 192 | const stateValidator = options.stateValidator || 193 | (options.state && ((s) => s === options.state)) || 194 | this.client.config.defaults?.stateValidator; 195 | 196 | if (stateValidator && !await stateValidator(state)) { 197 | if (state === null) { 198 | throw new AuthorizationResponseError("Missing state"); 199 | } else { 200 | throw new AuthorizationResponseError( 201 | `Invalid state: ${params.get("state")}`, 202 | ); 203 | } 204 | } 205 | 206 | if (state) { 207 | return { code, state }; 208 | } 209 | return { code }; 210 | } 211 | 212 | private buildAccessTokenRequest( 213 | code: string, 214 | codeVerifier?: string, 215 | requestOptions: RequestOptions = {}, 216 | ): Request { 217 | const body: Record = { 218 | "grant_type": "authorization_code", 219 | code, 220 | }; 221 | const headers: Record = { 222 | "Accept": "application/json", 223 | }; 224 | 225 | if (typeof codeVerifier === "string") { 226 | body.code_verifier = codeVerifier; 227 | } 228 | 229 | if (typeof this.client.config.redirectUri === "string") { 230 | body.redirect_uri = this.client.config.redirectUri; 231 | } 232 | 233 | if (typeof this.client.config.clientSecret === "string") { 234 | // We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1. 235 | const { clientId, clientSecret } = this.client.config; 236 | headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; 237 | } else { 238 | // This appears to be a public client, include the client ID along in the body 239 | body.client_id = this.client.config.clientId; 240 | } 241 | 242 | return this.buildRequest(this.client.config.tokenUri, { 243 | method: "POST", 244 | headers, 245 | body, 246 | }, requestOptions); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/authorization_code_grant_test.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { 3 | assertEquals, 4 | assertMatch, 5 | assertNotMatch, 6 | assertRejects, 7 | } from "@std/assert"; 8 | import { 9 | assertSpyCall, 10 | assertSpyCallAsync, 11 | assertSpyCalls, 12 | spy, 13 | } from "@std/testing/mock"; 14 | 15 | import { 16 | AuthorizationResponseError, 17 | OAuth2ResponseError, 18 | TokenResponseError, 19 | } from "./errors.ts"; 20 | import { 21 | assertMatchesUrl, 22 | buildAccessTokenCallback, 23 | getOAuth2Client, 24 | mockATResponse, 25 | } from "./test_utils.ts"; 26 | 27 | //#region AuthorizationCodeGrant.getAuthorizationUri successful paths (with PKCE) 28 | 29 | const urlBase64Regex = /^[a-z0-9_-]+$/i; 30 | 31 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works without additional options", async () => { 32 | const { uri, codeVerifier } = await getOAuth2Client().code 33 | .getAuthorizationUri(); 34 | 35 | const codeChallenge = uri.searchParams.get("code_challenge"); 36 | assertMatch(codeVerifier, urlBase64Regex); 37 | assertMatch(codeChallenge ?? "", urlBase64Regex); 38 | uri.searchParams.delete("code_challenge"); 39 | 40 | assertMatchesUrl( 41 | uri, 42 | "https://auth.server/auth?response_type=code&client_id=clientId&code_challenge_method=S256", 43 | ); 44 | }); 45 | 46 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing a single scope", async () => { 47 | const { uri, codeVerifier } = await getOAuth2Client().code 48 | .getAuthorizationUri({ 49 | scope: "singleScope", 50 | }); 51 | 52 | const codeChallenge = uri.searchParams.get("code_challenge"); 53 | assertMatch(codeVerifier, urlBase64Regex); 54 | assertMatch(codeChallenge ?? "", urlBase64Regex); 55 | uri.searchParams.delete("code_challenge"); 56 | 57 | assertMatchesUrl( 58 | uri, 59 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=singleScope&code_challenge_method=S256", 60 | ); 61 | }); 62 | 63 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing multiple scopes", async () => { 64 | const { uri, codeVerifier } = await getOAuth2Client().code 65 | .getAuthorizationUri({ 66 | scope: ["multiple", "scopes"], 67 | }); 68 | 69 | const codeChallenge = uri.searchParams.get("code_challenge"); 70 | assertMatch(codeVerifier, urlBase64Regex); 71 | assertMatch(codeChallenge ?? "", urlBase64Regex); 72 | uri.searchParams.delete("code_challenge"); 73 | 74 | assertMatchesUrl( 75 | uri, 76 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=multiple+scopes&code_challenge_method=S256", 77 | ); 78 | }); 79 | 80 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing a state parameter", async () => { 81 | const { uri, codeVerifier } = await getOAuth2Client().code 82 | .getAuthorizationUri({ 83 | state: "someState", 84 | }); 85 | 86 | const codeChallenge = uri.searchParams.get("code_challenge"); 87 | assertMatch(codeVerifier, urlBase64Regex); 88 | assertMatch(codeChallenge ?? "", urlBase64Regex); 89 | uri.searchParams.delete("code_challenge"); 90 | 91 | assertMatchesUrl( 92 | uri, 93 | "https://auth.server/auth?response_type=code&client_id=clientId&state=someState&code_challenge_method=S256", 94 | ); 95 | }); 96 | 97 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri", async () => { 98 | const { uri, codeVerifier } = await getOAuth2Client({ 99 | redirectUri: "https://example.app/redirect", 100 | }).code.getAuthorizationUri(); 101 | 102 | const codeChallenge = uri.searchParams.get("code_challenge"); 103 | assertMatch(codeVerifier, urlBase64Regex); 104 | assertMatch(codeChallenge ?? "", urlBase64Regex); 105 | uri.searchParams.delete("code_challenge"); 106 | 107 | assertMatchesUrl( 108 | uri, 109 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&code_challenge_method=S256", 110 | ); 111 | }); 112 | 113 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and a single scope", async () => { 114 | const { uri, codeVerifier } = await getOAuth2Client({ 115 | redirectUri: "https://example.app/redirect", 116 | }).code.getAuthorizationUri({ 117 | scope: "singleScope", 118 | }); 119 | 120 | const codeChallenge = uri.searchParams.get("code_challenge"); 121 | assertMatch(codeVerifier, urlBase64Regex); 122 | assertMatch(codeChallenge ?? "", urlBase64Regex); 123 | uri.searchParams.delete("code_challenge"); 124 | 125 | assertMatchesUrl( 126 | uri, 127 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=singleScope&code_challenge_method=S256", 128 | ); 129 | }); 130 | 131 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and multiple scopes", async () => { 132 | const { uri, codeVerifier } = await getOAuth2Client({ 133 | redirectUri: "https://example.app/redirect", 134 | }).code.getAuthorizationUri({ 135 | scope: ["multiple", "scopes"], 136 | }); 137 | 138 | const codeChallenge = uri.searchParams.get("code_challenge"); 139 | assertMatch(codeVerifier, urlBase64Regex); 140 | assertMatch(codeChallenge ?? "", urlBase64Regex); 141 | uri.searchParams.delete("code_challenge"); 142 | 143 | assertMatchesUrl( 144 | uri, 145 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=multiple+scopes&code_challenge_method=S256", 146 | ); 147 | }); 148 | 149 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses default scopes if no scope was specified", async () => { 150 | const { uri, codeVerifier } = await getOAuth2Client({ 151 | defaults: { scope: ["default", "scopes"] }, 152 | }).code.getAuthorizationUri(); 153 | 154 | const codeChallenge = uri.searchParams.get("code_challenge"); 155 | assertMatch(codeVerifier, urlBase64Regex); 156 | assertMatch(codeChallenge ?? "", urlBase64Regex); 157 | uri.searchParams.delete("code_challenge"); 158 | 159 | assertMatchesUrl( 160 | uri, 161 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=default+scopes&code_challenge_method=S256", 162 | ); 163 | }); 164 | 165 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses specified scopes over default scopes", async () => { 166 | const { uri, codeVerifier } = await getOAuth2Client({ 167 | defaults: { scope: ["default", "scopes"] }, 168 | }).code.getAuthorizationUri({ 169 | scope: "notDefault", 170 | }); 171 | 172 | const codeChallenge = uri.searchParams.get("code_challenge"); 173 | assertMatch(codeVerifier, urlBase64Regex); 174 | assertMatch(codeChallenge ?? "", urlBase64Regex); 175 | uri.searchParams.delete("code_challenge"); 176 | 177 | assertMatchesUrl( 178 | uri, 179 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=notDefault&code_challenge_method=S256", 180 | ); 181 | }); 182 | 183 | //#endregion 184 | 185 | //#region AuthorizationCodeGrant.getAuthorizationUri successful paths (without PKCE) 186 | 187 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works without additional options with PKCE disabled", async () => { 188 | assertMatchesUrl( 189 | (await getOAuth2Client().code.getAuthorizationUri({ disablePkce: true })) 190 | .uri, 191 | "https://auth.server/auth?response_type=code&client_id=clientId", 192 | ); 193 | }); 194 | 195 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing a single scope with PKCE disabled", async () => { 196 | assertMatchesUrl( 197 | (await getOAuth2Client().code.getAuthorizationUri({ 198 | scope: "singleScope", 199 | disablePkce: true, 200 | })).uri, 201 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=singleScope", 202 | ); 203 | }); 204 | 205 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing multiple scopes with PKCE disabled", async () => { 206 | assertMatchesUrl( 207 | (await getOAuth2Client().code.getAuthorizationUri({ 208 | scope: ["multiple", "scopes"], 209 | disablePkce: true, 210 | })).uri, 211 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=multiple+scopes", 212 | ); 213 | }); 214 | 215 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works when passing a state parameter with PKCE disabled", async () => { 216 | assertMatchesUrl( 217 | (await getOAuth2Client().code.getAuthorizationUri({ 218 | state: "someState", 219 | disablePkce: true, 220 | })).uri, 221 | "https://auth.server/auth?response_type=code&client_id=clientId&state=someState", 222 | ); 223 | }); 224 | 225 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri with PKCE disabled", async () => { 226 | assertMatchesUrl( 227 | (await getOAuth2Client({ 228 | redirectUri: "https://example.app/redirect", 229 | }).code.getAuthorizationUri({ disablePkce: true })).uri, 230 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect", 231 | ); 232 | }); 233 | 234 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and a single scope with PKCE disabled", async () => { 235 | assertMatchesUrl( 236 | (await getOAuth2Client({ 237 | redirectUri: "https://example.app/redirect", 238 | }).code.getAuthorizationUri({ 239 | scope: "singleScope", 240 | disablePkce: true, 241 | })).uri, 242 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=singleScope", 243 | ); 244 | }); 245 | 246 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri works with redirectUri and multiple scopes with PKCE disabled", async () => { 247 | assertMatchesUrl( 248 | (await getOAuth2Client({ 249 | redirectUri: "https://example.app/redirect", 250 | }).code.getAuthorizationUri({ 251 | scope: ["multiple", "scopes"], 252 | disablePkce: true, 253 | })).uri, 254 | "https://auth.server/auth?response_type=code&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=multiple+scopes", 255 | ); 256 | }); 257 | 258 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses default scopes if no scope was specified with PKCE disabled", async () => { 259 | assertMatchesUrl( 260 | (await getOAuth2Client({ 261 | defaults: { scope: ["default", "scopes"] }, 262 | }).code.getAuthorizationUri({ disablePkce: true })).uri, 263 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=default+scopes", 264 | ); 265 | }); 266 | 267 | Deno.test("AuthorizationCodeGrant.getAuthorizationUri uses specified scopes over default scopes with PKCE disabled", async () => { 268 | assertMatchesUrl( 269 | (await getOAuth2Client({ 270 | defaults: { scope: ["default", "scopes"] }, 271 | }).code.getAuthorizationUri({ 272 | scope: "notDefault", 273 | disablePkce: true, 274 | })).uri, 275 | "https://auth.server/auth?response_type=code&client_id=clientId&scope=notDefault", 276 | ); 277 | }); 278 | 279 | //#endregion 280 | 281 | //#region AuthorizationCodeGrant.getToken error paths 282 | 283 | Deno.test("AuthorizationCodeGrant.getToken throws if the received redirectUri does not match the configured one", async () => { 284 | await assertRejects( 285 | () => 286 | getOAuth2Client({ 287 | redirectUri: "https://example.com/redirect", 288 | }).code.getToken( 289 | buildAccessTokenCallback( 290 | { baseUrl: "https://example.com/invalid-redirect" }, 291 | ), 292 | ), 293 | AuthorizationResponseError, 294 | "Redirect path should match configured path", 295 | ); 296 | }); 297 | 298 | Deno.test("AuthorizationCodeGrant.getToken throws if the callbackUri does not contain any parameters", async () => { 299 | await assertRejects( 300 | () => 301 | getOAuth2Client().code.getToken( 302 | buildAccessTokenCallback(), 303 | ), 304 | AuthorizationResponseError, 305 | "URI does not contain callback parameters", 306 | ); 307 | }); 308 | 309 | Deno.test("AuthorizationCodeGrant.getToken throws if the callbackUri contains an error parameter", async () => { 310 | await assertRejects( 311 | () => 312 | getOAuth2Client().code.getToken( 313 | buildAccessTokenCallback({ 314 | params: { error: "invalid_request" }, 315 | }), 316 | ), 317 | OAuth2ResponseError, 318 | "invalid_request", 319 | ); 320 | }); 321 | 322 | Deno.test("AuthorizationCodeGrant.getToken throws if the callbackUri contains the error, error_description and error_uri parameters and adds them to the error object", async () => { 323 | const error = await assertRejects( 324 | () => 325 | getOAuth2Client().code.getToken( 326 | buildAccessTokenCallback({ 327 | params: { 328 | error: "invalid_request", 329 | error_description: "Error description", 330 | error_uri: "error://uri", 331 | }, 332 | }), 333 | ), 334 | OAuth2ResponseError, 335 | "Error description", 336 | ) as OAuth2ResponseError; 337 | assertEquals(error.error, "invalid_request"); 338 | assertEquals(error.errorDescription, "Error description"); 339 | assertEquals(error.errorUri, "error://uri"); 340 | }); 341 | 342 | Deno.test("AuthorizationCodeGrant.getToken throws if the callbackUri doesn't contain a code", async () => { 343 | await assertRejects( 344 | () => 345 | getOAuth2Client().code.getToken( 346 | buildAccessTokenCallback({ 347 | // some parameter has to be set or we'll get "URI does not contain callback parameters" instead 348 | params: { empty: "" } as any, 349 | }), 350 | ), 351 | AuthorizationResponseError, 352 | "Missing code, unable to request token", 353 | ); 354 | }); 355 | 356 | Deno.test("AuthorizationCodeGrant.getToken throws if it didn't receive a state and the state validator fails", async () => { 357 | await assertRejects( 358 | () => 359 | getOAuth2Client().code.getToken( 360 | buildAccessTokenCallback({ 361 | params: { code: "code" }, 362 | }), 363 | { stateValidator: () => false }, 364 | ), 365 | AuthorizationResponseError, 366 | "Missing state", 367 | ); 368 | }); 369 | 370 | Deno.test("AuthorizationCodeGrant.getToken throws if it didn't receive a state but a state was expected", async () => { 371 | await assertRejects( 372 | () => 373 | getOAuth2Client().code.getToken( 374 | buildAccessTokenCallback({ 375 | params: { code: "code" }, 376 | }), 377 | { state: "expected_state" }, 378 | ), 379 | AuthorizationResponseError, 380 | "Missing state", 381 | ); 382 | }); 383 | 384 | Deno.test("AuthorizationCodeGrant.getToken throws if it received a state that does not match the given state parameter", async () => { 385 | await assertRejects( 386 | () => 387 | getOAuth2Client().code.getToken( 388 | buildAccessTokenCallback({ 389 | params: { code: "code", state: "invalid_state" }, 390 | }), 391 | { state: "expected_state" }, 392 | ), 393 | AuthorizationResponseError, 394 | "Invalid state: invalid_state", 395 | ); 396 | }); 397 | 398 | Deno.test("AuthorizationCodeGrant.getToken throws if the stateValidator returns false", async () => { 399 | await assertRejects( 400 | () => 401 | getOAuth2Client().code.getToken( 402 | buildAccessTokenCallback({ 403 | params: { code: "code", state: "invalid_state" }, 404 | }), 405 | { stateValidator: () => false }, 406 | ), 407 | AuthorizationResponseError, 408 | "Invalid state: invalid_state", 409 | ); 410 | }); 411 | 412 | Deno.test("AuthorizationCodeGrant.getToken throws if the server responded with a Content-Type other than application/json", async () => { 413 | await assertRejects( 414 | () => 415 | mockATResponse( 416 | () => 417 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 418 | params: { code: "authCode" }, 419 | })), 420 | { body: "not json" }, 421 | ), 422 | TokenResponseError, 423 | "Invalid token response: Response is not JSON encoded", 424 | ); 425 | }); 426 | 427 | Deno.test("AuthorizationCodeGrant.getToken throws if the server responded with a correctly formatted error", async () => { 428 | await assertRejects( 429 | () => 430 | mockATResponse( 431 | () => 432 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 433 | params: { code: "authCode" }, 434 | })), 435 | { status: 401, body: { error: "invalid_client" } }, 436 | ), 437 | OAuth2ResponseError, 438 | "invalid_client", 439 | ); 440 | }); 441 | 442 | Deno.test("AuthorizationCodeGrant.getToken throws if the server responded with a 4xx or 5xx and the body doesn't contain an error parameter", async () => { 443 | await assertRejects( 444 | () => 445 | mockATResponse( 446 | () => 447 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 448 | params: { code: "authCode" }, 449 | })), 450 | { status: 401, body: {} }, 451 | ), 452 | TokenResponseError, 453 | "Invalid token response: Server returned 401 and no error description was given", 454 | ); 455 | }); 456 | 457 | Deno.test("AuthorizationCodeGrant.getToken throws if the server's response is not a JSON object", async () => { 458 | await assertRejects( 459 | () => 460 | mockATResponse( 461 | () => 462 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 463 | params: { code: "authCode" }, 464 | })), 465 | { body: '""' }, 466 | ), 467 | TokenResponseError, 468 | "Invalid token response: body is not a JSON object", 469 | ); 470 | await assertRejects( 471 | () => 472 | mockATResponse( 473 | () => 474 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 475 | params: { code: "authCode" }, 476 | })), 477 | { body: '["array values?!!"]' }, 478 | ), 479 | TokenResponseError, 480 | "Invalid token response: body is not a JSON object", 481 | ); 482 | }); 483 | 484 | Deno.test("AuthorizationCodeGrant.getToken throws if the server's response does not contain a token_type", async () => { 485 | await assertRejects( 486 | () => 487 | mockATResponse( 488 | () => 489 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 490 | params: { code: "authCode" }, 491 | })), 492 | { body: { access_token: "at" } }, 493 | ), 494 | TokenResponseError, 495 | "Invalid token response: missing token_type", 496 | ); 497 | }); 498 | 499 | Deno.test("AuthorizationCodeGrant.getToken throws if the server response's token_type is not a string", async () => { 500 | await assertRejects( 501 | () => 502 | mockATResponse( 503 | () => 504 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 505 | params: { code: "authCode" }, 506 | })), 507 | { 508 | body: { 509 | access_token: "at", 510 | token_type: 1337 as any, 511 | }, 512 | }, 513 | ), 514 | TokenResponseError, 515 | "Invalid token response: token_type is not a string", 516 | ); 517 | }); 518 | 519 | Deno.test("AuthorizationCodeGrant.getToken throws if the server's response does not contain an access_token", async () => { 520 | await assertRejects( 521 | () => 522 | mockATResponse( 523 | () => 524 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 525 | params: { code: "authCode" }, 526 | })), 527 | { body: { token_type: "tt" } }, 528 | ), 529 | TokenResponseError, 530 | "Invalid token response: missing access_token", 531 | ); 532 | }); 533 | 534 | Deno.test("AuthorizationCodeGrant.getToken throws if the server response's access_token is not a string", async () => { 535 | await assertRejects( 536 | () => 537 | mockATResponse( 538 | () => 539 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 540 | params: { code: "authCode" }, 541 | })), 542 | { 543 | body: { 544 | access_token: 1234 as any, 545 | token_type: "tt", 546 | }, 547 | }, 548 | ), 549 | TokenResponseError, 550 | "Invalid token response: access_token is not a string", 551 | ); 552 | }); 553 | 554 | Deno.test("AuthorizationCodeGrant.getToken throws if the server response's refresh_token property is not a string", async () => { 555 | await assertRejects( 556 | () => 557 | mockATResponse( 558 | () => 559 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 560 | params: { code: "authCode" }, 561 | })), 562 | { 563 | body: { 564 | access_token: "at", 565 | token_type: "tt", 566 | refresh_token: 123 as any, 567 | }, 568 | }, 569 | ), 570 | TokenResponseError, 571 | "Invalid token response: refresh_token is not a string", 572 | ); 573 | }); 574 | 575 | Deno.test("AuthorizationCodeGrant.getToken throws if the server response's expires_in property is not a number", async () => { 576 | await assertRejects( 577 | () => 578 | mockATResponse( 579 | () => 580 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 581 | params: { code: "authCode" }, 582 | })), 583 | { 584 | body: { 585 | access_token: "at", 586 | token_type: "tt", 587 | expires_in: { this: "is illegal" } as any, 588 | }, 589 | }, 590 | ), 591 | TokenResponseError, 592 | "Invalid token response: expires_in is not a number", 593 | ); 594 | }); 595 | 596 | Deno.test("AuthorizationCodeGrant.getToken throws if the server response's scope property is not a string", async () => { 597 | await assertRejects( 598 | () => 599 | mockATResponse( 600 | () => 601 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 602 | params: { code: "authCode" }, 603 | })), 604 | { 605 | body: { 606 | access_token: "at", 607 | token_type: "tt", 608 | scope: ["scope1", "scope2"] as any, 609 | }, 610 | }, 611 | ), 612 | TokenResponseError, 613 | "Invalid token response: scope is not a string", 614 | ); 615 | }); 616 | 617 | //#endregion 618 | 619 | //#region AuthorizationCodeGrant.getToken successful paths 620 | 621 | Deno.test("AuthorizationCodeGrant.getToken parses the minimal token response correctly", async () => { 622 | const { result } = await mockATResponse( 623 | () => 624 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 625 | params: { code: "authCode" }, 626 | })), 627 | { 628 | body: { 629 | access_token: "accessToken", 630 | token_type: "tokenType", 631 | }, 632 | }, 633 | ); 634 | assertEquals(result, { 635 | accessToken: "accessToken", 636 | tokenType: "tokenType", 637 | }); 638 | }); 639 | 640 | Deno.test("AuthorizationCodeGrant.getToken parses the full token response correctly", async () => { 641 | const { result } = await mockATResponse( 642 | () => 643 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 644 | params: { code: "authCode" }, 645 | })), 646 | { 647 | body: { 648 | access_token: "accessToken", 649 | token_type: "tokenType", 650 | refresh_token: "refreshToken", 651 | expires_in: 3600, 652 | scope: "multiple scopes", 653 | }, 654 | }, 655 | ); 656 | assertEquals(result, { 657 | accessToken: "accessToken", 658 | tokenType: "tokenType", 659 | refreshToken: "refreshToken", 660 | expiresIn: 3600, 661 | scope: ["multiple", "scopes"], 662 | }); 663 | }); 664 | 665 | Deno.test("AuthorizationCodeGrant.getToken supports async state validators", async () => { 666 | await mockATResponse( 667 | () => 668 | getOAuth2Client().code.getToken( 669 | buildAccessTokenCallback({ 670 | params: { code: "code" }, 671 | }), 672 | { stateValidator: () => Promise.resolve(true) }, 673 | ), 674 | ); 675 | }); 676 | 677 | Deno.test("AuthorizationCodeGrant.getToken doesn't throw if it didn't receive a state but the state validator returns true", async () => { 678 | await mockATResponse( 679 | () => 680 | getOAuth2Client().code.getToken( 681 | buildAccessTokenCallback({ 682 | params: { code: "code" }, 683 | }), 684 | { stateValidator: () => true }, 685 | ), 686 | ); 687 | }); 688 | 689 | Deno.test("AuthorizationCodeGrant.getToken builds a correct request to the token endpoint by default", async () => { 690 | const { request } = await mockATResponse( 691 | () => 692 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 693 | params: { code: "authCode" }, 694 | })), 695 | ); 696 | 697 | assertEquals(request.url, "https://auth.server/token"); 698 | const body = await request.formData(); 699 | assertEquals(body.get("grant_type"), "authorization_code"); 700 | assertEquals(body.get("code"), "authCode"); 701 | assertEquals(body.get("redirect_uri"), null); 702 | assertEquals(body.get("client_id"), "clientId"); 703 | assertEquals( 704 | request.headers.get("Content-Type"), 705 | "application/x-www-form-urlencoded", 706 | ); 707 | }); 708 | 709 | Deno.test("AuthorizationCodeGrant.getToken correctly adds the redirectUri to the token request if specified", async () => { 710 | const { request } = await mockATResponse( 711 | () => 712 | getOAuth2Client({ 713 | redirectUri: "http://some.redirect/uri", 714 | }).code.getToken(buildAccessTokenCallback({ 715 | baseUrl: "http://some.redirect/uri", 716 | params: { code: "authCode" }, 717 | })), 718 | ); 719 | assertEquals( 720 | (await request.formData()).get("redirect_uri"), 721 | "http://some.redirect/uri", 722 | ); 723 | }); 724 | 725 | Deno.test("AuthorizationCodeGrant.getToken sends the clientId as form parameter if no clientSecret is set", async () => { 726 | const { request } = await mockATResponse( 727 | () => 728 | getOAuth2Client().code.getToken(buildAccessTokenCallback({ 729 | params: { code: "authCode" }, 730 | })), 731 | ); 732 | assertEquals( 733 | (await request.formData()).get("client_id"), 734 | "clientId", 735 | ); 736 | assertEquals(request.headers.get("Authorization"), null); 737 | }); 738 | 739 | Deno.test("AuthorizationCodeGrant.getToken sends the correct Authorization header if the clientSecret is set", async () => { 740 | const { request } = await mockATResponse( 741 | () => 742 | getOAuth2Client({ clientSecret: "super-secret" }).code.getToken( 743 | buildAccessTokenCallback({ 744 | params: { code: "authCode" }, 745 | }), 746 | ), 747 | ); 748 | assertEquals( 749 | request.headers.get("Authorization"), 750 | "Basic Y2xpZW50SWQ6c3VwZXItc2VjcmV0", 751 | ); 752 | assertEquals((await request.formData()).get("client_id"), null); 753 | }); 754 | 755 | Deno.test("AuthorizationCodeGrant.getToken uses the default request options", async () => { 756 | const { request } = await mockATResponse( 757 | () => 758 | getOAuth2Client({ 759 | defaults: { 760 | requestOptions: { 761 | headers: { 762 | "User-Agent": "Custom User Agent", 763 | "Content-Type": "application/json", 764 | }, 765 | urlParams: { "custom-url-param": "value" }, 766 | body: { "custom-body-param": "value" }, 767 | }, 768 | }, 769 | }).code.getToken(buildAccessTokenCallback({ 770 | params: { code: "authCode" }, 771 | })), 772 | ); 773 | const url = new URL(request.url); 774 | assertEquals(url.searchParams.getAll("custom-url-param"), ["value"]); 775 | assertEquals(request.headers.get("Content-Type"), "application/json"); 776 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 777 | assertMatch(await request.text(), /.*custom-body-param=value.*/); 778 | }); 779 | 780 | Deno.test("AuthorizationCodeGrant.getToken uses the passed request options over the default options", async () => { 781 | const { request } = await mockATResponse( 782 | () => 783 | getOAuth2Client({ 784 | defaults: { 785 | requestOptions: { 786 | headers: { 787 | "User-Agent": "Custom User Agent", 788 | "Content-Type": "application/json", 789 | }, 790 | urlParams: { "custom-url-param": "value" }, 791 | body: { "custom-body-param": "value" }, 792 | }, 793 | }, 794 | }).code.getToken( 795 | buildAccessTokenCallback({ 796 | params: { code: "authCode" }, 797 | }), 798 | { 799 | requestOptions: { 800 | headers: { "Content-Type": "text/plain" }, 801 | urlParams: { "custom-url-param": "other_value" }, 802 | body: { "custom-body-param": "other_value" }, 803 | }, 804 | }, 805 | ), 806 | ); 807 | const url = new URL(request.url); 808 | assertEquals(url.searchParams.getAll("custom-url-param"), ["other_value"]); 809 | assertEquals(request.headers.get("Content-Type"), "text/plain"); 810 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 811 | 812 | const requestText = await request.text(); 813 | assertMatch(requestText, /.*custom-body-param=other_value.*/); 814 | assertNotMatch(requestText, /.*custom-body-param=value.*/); 815 | }); 816 | 817 | Deno.test("AuthorizationCodeGrant.getToken uses the default state validator if no state or validator was given", async () => { 818 | const defaultValidator = spy(() => true); 819 | 820 | await mockATResponse( 821 | () => 822 | getOAuth2Client({ 823 | defaults: { stateValidator: defaultValidator }, 824 | }).code.getToken(buildAccessTokenCallback({ 825 | params: { code: "authCode", state: "some_state" }, 826 | })), 827 | ); 828 | 829 | assertSpyCall(defaultValidator, 0, { args: ["some_state"], returned: true }); 830 | assertSpyCalls(defaultValidator, 1); 831 | }); 832 | 833 | Deno.test("AuthorizationCodeGrant.getToken supports async default state validators", async () => { 834 | const defaultValidator = spy(() => Promise.resolve(true)); 835 | 836 | await mockATResponse( 837 | () => 838 | getOAuth2Client({ 839 | defaults: { stateValidator: defaultValidator }, 840 | }).code.getToken(buildAccessTokenCallback({ 841 | params: { code: "authCode", state: "some_state" }, 842 | })), 843 | ); 844 | 845 | assertSpyCallAsync(defaultValidator, 0, { 846 | args: ["some_state"], 847 | returned: true, 848 | }); 849 | assertSpyCalls(defaultValidator, 1); 850 | }); 851 | 852 | Deno.test("AuthorizationCodeGrant.getToken uses the passed state validator over the default validator", async () => { 853 | const defaultValidator = spy(() => true); 854 | const validator = spy(() => true); 855 | 856 | await mockATResponse( 857 | () => 858 | getOAuth2Client({ 859 | defaults: { stateValidator: defaultValidator }, 860 | }).code.getToken( 861 | buildAccessTokenCallback({ 862 | params: { code: "authCode", state: "some_state" }, 863 | }), 864 | { stateValidator: validator }, 865 | ), 866 | ); 867 | 868 | assertSpyCalls(defaultValidator, 0); 869 | assertSpyCall(validator, 0, { args: ["some_state"], returned: true }); 870 | assertSpyCalls(validator, 1); 871 | }); 872 | 873 | Deno.test("AuthorizationCodeGrant.getToken uses the passed state validator over the passed state", async () => { 874 | const defaultValidator = spy(() => true); 875 | const validator = spy(() => true); 876 | 877 | await mockATResponse( 878 | () => 879 | getOAuth2Client({ 880 | defaults: { stateValidator: defaultValidator }, 881 | }).code.getToken( 882 | buildAccessTokenCallback({ 883 | params: { code: "authCode", state: "some_state" }, 884 | }), 885 | { stateValidator: validator, state: "other_state" }, 886 | ), 887 | ); 888 | 889 | assertSpyCalls(defaultValidator, 0); 890 | assertSpyCall(validator, 0, { args: ["some_state"], returned: true }); 891 | assertSpyCalls(validator, 1); 892 | }); 893 | 894 | //#endregion 895 | -------------------------------------------------------------------------------- /src/client_credentials_grant.ts: -------------------------------------------------------------------------------- 1 | import { MissingClientSecretError } from "./errors.ts"; 2 | import { OAuth2GrantBase } from "./grant_base.ts"; 3 | import type { OAuth2Client } from "./oauth2_client.ts"; 4 | import type { RequestOptions, Tokens } from "./types.ts"; 5 | 6 | export interface ClientCredentialsTokenOptions { 7 | /** 8 | * Scopes to request with the authorization request. 9 | * 10 | * If an array is passed, it is concatenated using spaces as per 11 | * https://tools.ietf.org/html/rfc6749#section-3.3 12 | */ 13 | scope?: string | string[]; 14 | 15 | /** Request options used when making the access token request. */ 16 | requestOptions?: RequestOptions; 17 | } 18 | 19 | /** 20 | * Implements the OAuth 2.0 Client Credentials grant. 21 | * 22 | * See https://tools.ietf.org/html/rfc6749#section-4.4 23 | */ 24 | export class ClientCredentialsGrant extends OAuth2GrantBase { 25 | constructor(client: OAuth2Client) { 26 | super(client); 27 | } 28 | 29 | /** 30 | * Uses the clientId and clientSecret to request an access token 31 | */ 32 | public async getToken( 33 | options: ClientCredentialsTokenOptions = {}, 34 | ): Promise { 35 | const request = this.buildTokenRequest(options); 36 | 37 | const accessTokenResponse = await fetch(request); 38 | 39 | return this.parseTokenResponse(accessTokenResponse); 40 | } 41 | 42 | private buildTokenRequest( 43 | options: ClientCredentialsTokenOptions, 44 | ): Request { 45 | const { clientId, clientSecret } = this.client.config; 46 | if (typeof clientSecret !== "string") { 47 | throw new MissingClientSecretError(); 48 | } 49 | 50 | const body: Record = { 51 | "grant_type": "client_credentials", 52 | }; 53 | const headers: Record = { 54 | "Accept": "application/json", 55 | // We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1. 56 | "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, 57 | }; 58 | 59 | const scope = options.scope ?? this.client.config.defaults?.scope; 60 | if (scope) { 61 | if (Array.isArray(scope)) { 62 | body.scope = scope.join(" "); 63 | } else { 64 | body.scope = scope; 65 | } 66 | } 67 | 68 | return this.buildRequest(this.client.config.tokenUri, { 69 | method: "POST", 70 | headers, 71 | body, 72 | }, options.requestOptions); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/client_credentials_grant_test.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { 3 | assertEquals, 4 | assertMatch, 5 | assertNotMatch, 6 | assertRejects, 7 | } from "@std/assert"; 8 | 9 | import { 10 | MissingClientSecretError, 11 | OAuth2ResponseError, 12 | TokenResponseError, 13 | } from "./errors.ts"; 14 | import { getOAuth2Client, mockATResponse } from "./test_utils.ts"; 15 | 16 | //#region ClientCredentialsGrant.getToken error paths 17 | 18 | Deno.test("ClientCredentialsGrant.getToken throws when no client secret was configured", async () => { 19 | await assertRejects( 20 | () => getOAuth2Client().clientCredentials.getToken(), 21 | MissingClientSecretError, 22 | "this grant requires a clientSecret to be set", 23 | ); 24 | }); 25 | 26 | Deno.test("ClientCredentialsGrant.getToken throws if the server responded with a Content-Type other than application/json", async () => { 27 | await assertRejects( 28 | () => 29 | mockATResponse( 30 | () => 31 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 32 | .getToken(), 33 | { body: "not json" }, 34 | ), 35 | TokenResponseError, 36 | "Invalid token response: Response is not JSON encoded", 37 | ); 38 | }); 39 | 40 | Deno.test("ClientCredentialsGrant.getToken throws if the server responded with a correctly formatted error", async () => { 41 | await assertRejects( 42 | () => 43 | mockATResponse( 44 | () => 45 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 46 | .getToken(), 47 | { status: 401, body: { error: "invalid_client" } }, 48 | ), 49 | OAuth2ResponseError, 50 | "invalid_client", 51 | ); 52 | }); 53 | 54 | Deno.test("ClientCredentialsGrant.getToken throws if the server responded with a 4xx or 5xx and the body doesn't contain an error parameter", async () => { 55 | await assertRejects( 56 | () => 57 | mockATResponse( 58 | () => 59 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 60 | .getToken(), 61 | { status: 401, body: {} }, 62 | ), 63 | TokenResponseError, 64 | "Invalid token response: Server returned 401 and no error description was given", 65 | ); 66 | }); 67 | 68 | Deno.test("ClientCredentialsGrant.getToken throws if the server's response is not a JSON object", async () => { 69 | await assertRejects( 70 | () => 71 | mockATResponse( 72 | () => 73 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 74 | .getToken(), 75 | { body: '""' }, 76 | ), 77 | TokenResponseError, 78 | "Invalid token response: body is not a JSON object", 79 | ); 80 | await assertRejects( 81 | () => 82 | mockATResponse( 83 | () => 84 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 85 | .getToken(), 86 | { body: '["array values?!!"]' }, 87 | ), 88 | TokenResponseError, 89 | "Invalid token response: body is not a JSON object", 90 | ); 91 | }); 92 | 93 | Deno.test("ClientCredentialsGrant.getToken throws if the server's response does not contain a token_type", async () => { 94 | await assertRejects( 95 | () => 96 | mockATResponse( 97 | () => 98 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 99 | .getToken(), 100 | { body: { access_token: "at" } }, 101 | ), 102 | TokenResponseError, 103 | "Invalid token response: missing token_type", 104 | ); 105 | }); 106 | 107 | Deno.test("ClientCredentialsGrant.getToken throws if the server response's token_type is not a string", async () => { 108 | await assertRejects( 109 | () => 110 | mockATResponse( 111 | () => 112 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 113 | .getToken(), 114 | { 115 | body: { 116 | access_token: "at", 117 | token_type: 1337 as any, 118 | }, 119 | }, 120 | ), 121 | TokenResponseError, 122 | "Invalid token response: token_type is not a string", 123 | ); 124 | }); 125 | 126 | Deno.test("ClientCredentialsGrant.getToken throws if the server's response does not contain an access_token", async () => { 127 | await assertRejects( 128 | () => 129 | mockATResponse( 130 | () => 131 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 132 | .getToken(), 133 | { body: { token_type: "tt" } }, 134 | ), 135 | TokenResponseError, 136 | "Invalid token response: missing access_token", 137 | ); 138 | }); 139 | 140 | Deno.test("ClientCredentialsGrant.getToken throws if the server response's access_token is not a string", async () => { 141 | await assertRejects( 142 | () => 143 | mockATResponse( 144 | () => 145 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 146 | .getToken(), 147 | { 148 | body: { 149 | access_token: 1234 as any, 150 | token_type: "tt", 151 | }, 152 | }, 153 | ), 154 | TokenResponseError, 155 | "Invalid token response: access_token is not a string", 156 | ); 157 | }); 158 | 159 | Deno.test("ClientCredentialsGrant.getToken throws if the server response's refresh_token property is not a string", async () => { 160 | await assertRejects( 161 | () => 162 | mockATResponse( 163 | () => 164 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 165 | .getToken(), 166 | { 167 | body: { 168 | access_token: "at", 169 | token_type: "tt", 170 | refresh_token: 123 as any, 171 | }, 172 | }, 173 | ), 174 | TokenResponseError, 175 | "Invalid token response: refresh_token is not a string", 176 | ); 177 | }); 178 | 179 | Deno.test("ClientCredentialsGrant.getToken throws if the server response's expires_in property is not a number", async () => { 180 | await assertRejects( 181 | () => 182 | mockATResponse( 183 | () => 184 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 185 | .getToken(), 186 | { 187 | body: { 188 | access_token: "at", 189 | token_type: "tt", 190 | expires_in: { this: "is illegal" } as any, 191 | }, 192 | }, 193 | ), 194 | TokenResponseError, 195 | "Invalid token response: expires_in is not a number", 196 | ); 197 | }); 198 | 199 | Deno.test("ClientCredentialsGrant.getToken throws if the server response's scope property is not a string", async () => { 200 | await assertRejects( 201 | () => 202 | mockATResponse( 203 | () => 204 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials 205 | .getToken(), 206 | { 207 | body: { 208 | access_token: "at", 209 | token_type: "tt", 210 | scope: ["scope1", "scope2"] as any, 211 | }, 212 | }, 213 | ), 214 | TokenResponseError, 215 | "Invalid token response: scope is not a string", 216 | ); 217 | }); 218 | 219 | //#endregion 220 | 221 | //#region ClientCredentialsGrant.getToken successful paths 222 | 223 | Deno.test("ClientCredentialsGrant.getToken builds a correct request to the token endpoint by default", async () => { 224 | const { request } = await mockATResponse( 225 | () => 226 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials.getToken(), 227 | ); 228 | 229 | assertEquals(request.url, "https://auth.server/token"); 230 | const body = await request.formData(); 231 | assertEquals(body.get("grant_type"), "client_credentials"); 232 | assertEquals( 233 | request.headers.get("Content-Type"), 234 | "application/x-www-form-urlencoded", 235 | ); 236 | assertEquals( 237 | request.headers.get("Authorization"), 238 | "Basic Y2xpZW50SWQ6c2VjcmV0", 239 | ); 240 | 241 | assertEquals([...body.keys()].length, 1); 242 | }); 243 | 244 | Deno.test("ClientCredentialsGrant.getToken includes the passed scope in the token endpoint request", async () => { 245 | const { request } = await mockATResponse( 246 | () => 247 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials.getToken({ 248 | scope: "singleScope", 249 | }), 250 | ); 251 | 252 | assertEquals(request.url, "https://auth.server/token"); 253 | const body = await request.formData(); 254 | assertEquals(body.get("grant_type"), "client_credentials"); 255 | assertEquals(body.get("scope"), "singleScope"); 256 | assertEquals( 257 | request.headers.get("Content-Type"), 258 | "application/x-www-form-urlencoded", 259 | ); 260 | assertEquals( 261 | request.headers.get("Authorization"), 262 | "Basic Y2xpZW50SWQ6c2VjcmV0", 263 | ); 264 | 265 | assertEquals([...body.keys()].length, 2); 266 | }); 267 | 268 | Deno.test("ClientCredentialsGrant.getToken includes the passed scopes in the token endpoint request", async () => { 269 | const { request } = await mockATResponse( 270 | () => 271 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials.getToken({ 272 | scope: ["multiple", "scopes"], 273 | }), 274 | ); 275 | 276 | assertEquals(request.url, "https://auth.server/token"); 277 | const body = await request.formData(); 278 | assertEquals(body.get("grant_type"), "client_credentials"); 279 | assertEquals(body.get("scope"), "multiple scopes"); 280 | assertEquals( 281 | request.headers.get("Content-Type"), 282 | "application/x-www-form-urlencoded", 283 | ); 284 | assertEquals( 285 | request.headers.get("Authorization"), 286 | "Basic Y2xpZW50SWQ6c2VjcmV0", 287 | ); 288 | 289 | assertEquals([...body.keys()].length, 2); 290 | }); 291 | 292 | Deno.test("ClientCredentialsGrant.getToken includes default scopes in the token endpoint request", async () => { 293 | const { request } = await mockATResponse( 294 | () => 295 | getOAuth2Client({ 296 | clientSecret: "secret", 297 | defaults: { scope: ["default", "scopes"] }, 298 | }).clientCredentials.getToken(), 299 | ); 300 | 301 | assertEquals(request.url, "https://auth.server/token"); 302 | const body = await request.formData(); 303 | assertEquals(body.get("grant_type"), "client_credentials"); 304 | assertEquals(body.get("scope"), "default scopes"); 305 | assertEquals( 306 | request.headers.get("Content-Type"), 307 | "application/x-www-form-urlencoded", 308 | ); 309 | assertEquals( 310 | request.headers.get("Authorization"), 311 | "Basic Y2xpZW50SWQ6c2VjcmV0", 312 | ); 313 | 314 | assertEquals([...body.keys()].length, 2); 315 | }); 316 | 317 | Deno.test("ClientCredentialsGrant.getToken does not include default scopes in the token endpoint request when different scopes were passed", async () => { 318 | const { request } = await mockATResponse( 319 | () => 320 | getOAuth2Client({ 321 | clientSecret: "secret", 322 | defaults: { scope: ["default", "scopes"] }, 323 | }).clientCredentials.getToken({ scope: "notDefault" }), 324 | ); 325 | 326 | assertEquals(request.url, "https://auth.server/token"); 327 | const body = await request.formData(); 328 | assertEquals(body.get("grant_type"), "client_credentials"); 329 | assertEquals(body.get("scope"), "notDefault"); 330 | assertEquals( 331 | request.headers.get("Content-Type"), 332 | "application/x-www-form-urlencoded", 333 | ); 334 | assertEquals( 335 | request.headers.get("Authorization"), 336 | "Basic Y2xpZW50SWQ6c2VjcmV0", 337 | ); 338 | 339 | assertEquals([...body.keys()].length, 2); 340 | }); 341 | 342 | Deno.test("ClientCredentialsGrant.getToken uses the default request options", async () => { 343 | const { request } = await mockATResponse( 344 | () => 345 | getOAuth2Client({ 346 | clientSecret: "secret", 347 | defaults: { 348 | requestOptions: { 349 | headers: { 350 | "User-Agent": "Custom User Agent", 351 | "Content-Type": "application/json", 352 | }, 353 | urlParams: { "custom-url-param": "value" }, 354 | body: { "custom-body-param": "value" }, 355 | }, 356 | }, 357 | }).clientCredentials.getToken(), 358 | ); 359 | const url = new URL(request.url); 360 | assertEquals(url.searchParams.getAll("custom-url-param"), ["value"]); 361 | assertEquals(request.headers.get("Content-Type"), "application/json"); 362 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 363 | assertMatch(await request.text(), /.*custom-body-param=value.*/); 364 | }); 365 | 366 | Deno.test("ClientCredentialsGrant.getToken uses the passed request options over the default options", async () => { 367 | const { request } = await mockATResponse( 368 | () => 369 | getOAuth2Client({ 370 | clientSecret: "secret", 371 | defaults: { 372 | requestOptions: { 373 | headers: { 374 | "User-Agent": "Custom User Agent", 375 | "Content-Type": "application/json", 376 | }, 377 | urlParams: { "custom-url-param": "value" }, 378 | body: { "custom-body-param": "value" }, 379 | }, 380 | }, 381 | }).clientCredentials.getToken({ 382 | requestOptions: { 383 | headers: { "Content-Type": "text/plain" }, 384 | urlParams: { "custom-url-param": "other_value" }, 385 | body: { "custom-body-param": "other_value" }, 386 | }, 387 | }), 388 | ); 389 | const url = new URL(request.url); 390 | assertEquals(url.searchParams.getAll("custom-url-param"), ["other_value"]); 391 | assertEquals(request.headers.get("Content-Type"), "text/plain"); 392 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 393 | 394 | const requestText = await request.text(); 395 | assertMatch(requestText, /.*custom-body-param=other_value.*/); 396 | assertNotMatch(requestText, /.*custom-body-param=value.*/); 397 | }); 398 | 399 | Deno.test("ClientCredentialsGrant.getToken parses the minimal token response correctly", async () => { 400 | const { result } = await mockATResponse( 401 | () => 402 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials.getToken(), 403 | { 404 | body: { 405 | access_token: "accessToken", 406 | token_type: "tokenType", 407 | }, 408 | }, 409 | ); 410 | assertEquals(result, { 411 | accessToken: "accessToken", 412 | tokenType: "tokenType", 413 | }); 414 | }); 415 | 416 | Deno.test("ClientCredentialsGrant.getToken parses the full token response correctly", async () => { 417 | const { result } = await mockATResponse( 418 | () => 419 | getOAuth2Client({ clientSecret: "secret" }).clientCredentials.getToken(), 420 | { 421 | body: { 422 | access_token: "accessToken", 423 | token_type: "tokenType", 424 | refresh_token: "refreshToken", 425 | expires_in: 3600, 426 | scope: "multiple scopes", 427 | }, 428 | }, 429 | ); 430 | assertEquals(result, { 431 | accessToken: "accessToken", 432 | tokenType: "tokenType", 433 | refreshToken: "refreshToken", 434 | expiresIn: 3600, 435 | scope: ["multiple", "scopes"], 436 | }); 437 | }); 438 | 439 | //#endregion 440 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | interface ErrorResponseParams { 2 | error: string; 3 | "error_description"?: string; 4 | "error_uri"?: string; 5 | state?: string; 6 | } 7 | 8 | /** Thrown when trying to use a grant that requires the client secret to be set */ 9 | export class MissingClientSecretError extends Error { 10 | constructor() { 11 | super("this grant requires a clientSecret to be set"); 12 | } 13 | } 14 | 15 | /** Generic error returned by an OAuth 2.0 authorization server. */ 16 | export class OAuth2ResponseError extends Error { 17 | public readonly error: string; 18 | public readonly errorDescription?: string; 19 | public readonly errorUri?: string; 20 | public readonly state?: string; 21 | 22 | constructor(response: ErrorResponseParams) { 23 | super(response.error_description || response.error); 24 | 25 | this.error = response.error; 26 | this.errorDescription = response.error_description; 27 | this.errorUri = response.error_uri; 28 | this.state = response.state; 29 | } 30 | 31 | public static fromURLSearchParams( 32 | params: URLSearchParams, 33 | ): OAuth2ResponseError { 34 | const error = params.get("error"); 35 | if (error === null) { 36 | throw new TypeError("error URL parameter must be set"); 37 | } 38 | const response: ErrorResponseParams = { 39 | error: params.get("error") as string, 40 | }; 41 | 42 | const description = params.get("error_description"); 43 | if (description !== null) { 44 | response.error_description = description; 45 | } 46 | 47 | const uri = params.get("error_uri"); 48 | if (uri !== null) { 49 | response.error_uri = uri; 50 | } 51 | 52 | const state = params.get("state"); 53 | if (state !== null) { 54 | response.state = state; 55 | } 56 | 57 | return new OAuth2ResponseError(response); 58 | } 59 | } 60 | 61 | /** Error originating from the authorization response. */ 62 | export class AuthorizationResponseError extends Error { 63 | constructor(description: string) { 64 | super(`Invalid authorization response: ${description}`); 65 | } 66 | } 67 | 68 | /** Error originating from the token response. */ 69 | export class TokenResponseError extends Error { 70 | public readonly response: Response; 71 | 72 | constructor(description: string, response: Response) { 73 | super(`Invalid token response: ${description}`); 74 | this.response = response; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/errors_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "@std/assert"; 2 | import { 3 | AuthorizationResponseError, 4 | OAuth2ResponseError, 5 | TokenResponseError, 6 | } from "./errors.ts"; 7 | 8 | Deno.test("OAuth2ResponseError constructor works", () => { 9 | const fullError = new OAuth2ResponseError({ 10 | error: "invalid_request", 11 | error_description: "test description", 12 | error_uri: "error://uri", 13 | state: "some state", 14 | }); 15 | assertEquals(fullError.message, "test description"); 16 | assertEquals(fullError.error, "invalid_request"); 17 | assertEquals(fullError.errorDescription, "test description"); 18 | assertEquals(fullError.errorUri, "error://uri"); 19 | assertEquals(fullError.state, "some state"); 20 | 21 | const noDescription = new OAuth2ResponseError({ 22 | error: "test error", 23 | }); 24 | assertEquals(noDescription.message, "test error"); 25 | }); 26 | 27 | Deno.test("OAuth2ResponseError.fromURLSearchParams, URL without error parameter throws error", () => { 28 | assertThrows( 29 | () => { 30 | OAuth2ResponseError.fromURLSearchParams(new URLSearchParams()); 31 | }, 32 | TypeError, 33 | "error URL parameter must be set", 34 | ); 35 | }); 36 | 37 | Deno.test("OAuth2ResponseError.fromURLSearchParams works when only the error parameter is set", () => { 38 | const onlyError = OAuth2ResponseError.fromURLSearchParams( 39 | new URLSearchParams({ 40 | error: "test error", 41 | }), 42 | ); 43 | assertEquals(onlyError.error, "test error"); 44 | assertEquals(onlyError.errorDescription, undefined); 45 | assertEquals(onlyError.errorUri, undefined); 46 | assertEquals(onlyError.state, undefined); 47 | assertEquals(onlyError.message, "test error"); 48 | }); 49 | 50 | Deno.test("OAuth2ResponseError.fromURLSearchParams works when error and error_description are set", () => { 51 | const withDescription = OAuth2ResponseError.fromURLSearchParams( 52 | new URLSearchParams({ 53 | error: "test error", 54 | error_description: "description", 55 | }), 56 | ); 57 | assertEquals(withDescription.errorDescription, "description"); 58 | assertEquals(withDescription.message, "description"); 59 | }); 60 | 61 | Deno.test("OAuth2ResponseError.fromURLSearchParams works when error and error_uri are set", () => { 62 | const withErrorUri = OAuth2ResponseError.fromURLSearchParams( 63 | new URLSearchParams({ 64 | error: "test error", 65 | error_uri: "error://uri", 66 | }), 67 | ); 68 | assertEquals(withErrorUri.errorUri, "error://uri"); 69 | assertEquals(withErrorUri.message, "test error"); 70 | }); 71 | 72 | Deno.test("OAuth2ResponseError.fromURLSearchParams works when error and state are set", () => { 73 | const withState = OAuth2ResponseError.fromURLSearchParams( 74 | new URLSearchParams({ 75 | error: "test error", 76 | state: "some state", 77 | }), 78 | ); 79 | assertEquals(withState.state, "some state"); 80 | assertEquals(withState.message, "test error"); 81 | }); 82 | 83 | Deno.test("OAuth2ResponseError.fromURLSearchParams works when error, error_description, error_uri and state are set", () => { 84 | const fullError = OAuth2ResponseError.fromURLSearchParams( 85 | new URLSearchParams({ 86 | error: "invalid_request", 87 | error_description: "test description", 88 | error_uri: "error://uri", 89 | state: "some state", 90 | }), 91 | ); 92 | assertEquals(fullError.message, "test description"); 93 | assertEquals(fullError.error, "invalid_request"); 94 | assertEquals(fullError.errorDescription, "test description"); 95 | assertEquals(fullError.errorUri, "error://uri"); 96 | assertEquals(fullError.state, "some state"); 97 | }); 98 | 99 | Deno.test("AuthorizationResponseError constructor works", () => { 100 | const error = new AuthorizationResponseError("description"); 101 | 102 | assertEquals(error.message, "Invalid authorization response: description"); 103 | }); 104 | 105 | Deno.test("TokenResponseError constructor works", () => { 106 | const response = new Response("body", { 107 | headers: { "Test-Header": "is set" }, 108 | status: 418, 109 | statusText: "I'm a teapot", 110 | }); 111 | const error = new TokenResponseError("description", response); 112 | 113 | assertEquals(error.message, "Invalid token response: description"); 114 | assertEquals(error.response, response); 115 | }); 116 | -------------------------------------------------------------------------------- /src/grant_base.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2ResponseError, TokenResponseError } from "./errors.ts"; 2 | import type { OAuth2Client } from "./oauth2_client.ts"; 3 | import type { RequestOptions, Tokens } from "./types.ts"; 4 | 5 | interface AccessTokenResponse { 6 | "access_token": string; 7 | "token_type": string; 8 | "expires_in"?: number; 9 | "refresh_token"?: string; 10 | scope?: string; 11 | } 12 | 13 | /** 14 | * Base class for all grants. 15 | * 16 | * Contains methods useful to most if not all implementations of OAuth 2.0 grants. 17 | */ 18 | export abstract class OAuth2GrantBase { 19 | constructor( 20 | protected readonly client: OAuth2Client, 21 | ) {} 22 | 23 | protected buildRequest( 24 | baseUrl: string | URL, 25 | options: RequestOptions, 26 | overrideOptions: RequestOptions = {}, 27 | ): Request { 28 | const url = this.toUrl(baseUrl); 29 | 30 | const clientDefaults = this.client.config.defaults?.requestOptions; 31 | 32 | const urlParams: Record = { 33 | ...(clientDefaults?.urlParams), 34 | ...(options.urlParams ?? {}), 35 | ...(overrideOptions.urlParams ?? {}), 36 | }; 37 | Object.keys(urlParams).forEach((key) => { 38 | url.searchParams.append(key, urlParams[key]); 39 | }); 40 | 41 | const method = overrideOptions.method ?? 42 | options.method ?? 43 | "GET"; 44 | 45 | return new Request(url.toString(), { 46 | method, 47 | headers: new Headers({ 48 | "Content-Type": "application/x-www-form-urlencoded", 49 | ...(clientDefaults?.headers ?? {}), 50 | ...(options.headers ?? {}), 51 | ...(overrideOptions.headers ?? {}), 52 | }), 53 | body: method !== "HEAD" && method !== "GET" 54 | ? new URLSearchParams({ 55 | ...(clientDefaults?.body ?? {}), 56 | ...(options.body ?? {}), 57 | ...(overrideOptions.body ?? {}), 58 | }).toString() 59 | : undefined, 60 | }); 61 | } 62 | 63 | protected toUrl(url: string | URL): URL { 64 | if (typeof url === "string") { 65 | return new URL(url, "http://example.com"); 66 | } 67 | return url; 68 | } 69 | 70 | protected async parseTokenResponse(response: Response): Promise { 71 | if (!response.ok) { 72 | throw await this.getTokenResponseError(response); 73 | } 74 | 75 | let body: AccessTokenResponse; 76 | try { 77 | body = await response.clone().json(); 78 | } catch { 79 | throw new TokenResponseError( 80 | "Response is not JSON encoded", 81 | response, 82 | ); 83 | } 84 | 85 | if (typeof body !== "object" || Array.isArray(body) || body === null) { 86 | throw new TokenResponseError( 87 | "body is not a JSON object", 88 | response, 89 | ); 90 | } 91 | if (typeof body.access_token !== "string") { 92 | throw new TokenResponseError( 93 | body.access_token 94 | ? "access_token is not a string" 95 | : "missing access_token", 96 | response, 97 | ); 98 | } 99 | if (typeof body.token_type !== "string") { 100 | throw new TokenResponseError( 101 | body.token_type ? "token_type is not a string" : "missing token_type", 102 | response, 103 | ); 104 | } 105 | if ( 106 | body.refresh_token !== undefined && 107 | typeof body.refresh_token !== "string" 108 | ) { 109 | throw new TokenResponseError( 110 | "refresh_token is not a string", 111 | response, 112 | ); 113 | } 114 | if ( 115 | body.expires_in !== undefined && typeof body.expires_in !== "number" 116 | ) { 117 | throw new TokenResponseError( 118 | "expires_in is not a number", 119 | response, 120 | ); 121 | } 122 | if (body.scope !== undefined && typeof body.scope !== "string") { 123 | throw new TokenResponseError( 124 | "scope is not a string", 125 | response, 126 | ); 127 | } 128 | 129 | const tokens: Tokens = { 130 | accessToken: body.access_token, 131 | tokenType: body.token_type, 132 | }; 133 | 134 | if (body.refresh_token) { 135 | tokens.refreshToken = body.refresh_token; 136 | } 137 | if (body.expires_in) { 138 | tokens.expiresIn = body.expires_in; 139 | } 140 | if (body.scope) { 141 | tokens.scope = body.scope.split(" "); 142 | } 143 | 144 | return tokens; 145 | } 146 | 147 | /** Tries to build an AuthError from the response and defaults to AuthServerResponseError if that fails. */ 148 | private async getTokenResponseError( 149 | response: Response, 150 | ): Promise { 151 | try { 152 | const body = await response.json(); 153 | if (typeof body.error !== "string") { 154 | throw new TypeError("body should contain an error"); 155 | } 156 | return new OAuth2ResponseError(body); 157 | } catch { 158 | return new TokenResponseError( 159 | `Server returned ${response.status} and no error description was given`, 160 | response, 161 | ); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/grant_base_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert/equals"; 2 | import { OAuth2Client, type OAuth2ClientConfig } from "./oauth2_client.ts"; 3 | import { OAuth2GrantBase } from "./grant_base.ts"; 4 | import type { RequestOptions } from "./types.ts"; 5 | 6 | class OAuth2Grant extends OAuth2GrantBase { 7 | public override buildRequest( 8 | baseUrl: string | URL, 9 | options: RequestOptions, 10 | overrideOptions: RequestOptions = {}, 11 | ): Request { 12 | return super.buildRequest(baseUrl, options, overrideOptions); 13 | } 14 | } 15 | 16 | function getGrantBase(overrideConfig: Partial = {}) { 17 | return new OAuth2Grant( 18 | new OAuth2Client({ 19 | clientId: "clientId", 20 | authorizationEndpointUri: "https://auth.server/auth", 21 | tokenUri: "https://auth.server/token", 22 | ...overrideConfig, 23 | }), 24 | ); 25 | } 26 | 27 | Deno.test("OAuth2GrantBase.buildRequest works without optional parameters", async () => { 28 | const req = getGrantBase().buildRequest("https://auth.server/req", {}); 29 | 30 | assertEquals(await req.text(), ""); 31 | assertEquals( 32 | [...req.headers], 33 | [["content-type", "application/x-www-form-urlencoded"]], 34 | ); 35 | assertEquals(req.method, "GET"); 36 | assertEquals(req.url, "https://auth.server/req"); 37 | }); 38 | 39 | Deno.test("OAuth2GrantBase.buildRequest works with overrideOptions set", async () => { 40 | const req = getGrantBase({ 41 | defaults: { 42 | requestOptions: { 43 | body: { 44 | default1: "default", 45 | default2: "default", 46 | default3: "default", 47 | }, 48 | headers: { 49 | "default-header1": "default", 50 | "default-header2": "default", 51 | "default-header3": "default", 52 | }, 53 | urlParams: { 54 | "defaultParam1": "default", 55 | "defaultParam2": "default", 56 | "defaultParam3": "default", 57 | }, 58 | }, 59 | }, 60 | }).buildRequest("https://auth.server/req", { 61 | body: { 62 | default2: "request", 63 | default3: "request", 64 | request1: "request", 65 | request2: "request", 66 | }, 67 | headers: { 68 | "default-header2": "request", 69 | "default-header3": "request", 70 | "request-header1": "request", 71 | "request-header2": "request", 72 | }, 73 | method: "POST", 74 | urlParams: { 75 | defaultParam2: "request", 76 | defaultParam3: "request", 77 | requestParam1: "request", 78 | requestParam2: "request", 79 | }, 80 | }, { 81 | body: { 82 | default3: "override", 83 | request2: "override", 84 | override1: "override", 85 | }, 86 | headers: { 87 | "default-header3": "override", 88 | "request-header2": "override", 89 | "override-header1": "override", 90 | }, 91 | method: "DELETE", 92 | urlParams: { 93 | defaultParam3: "override", 94 | requestParam2: "override", 95 | overrideParam1: "override", 96 | }, 97 | }); 98 | 99 | const formData = new URLSearchParams(await req.text()); 100 | assertEquals(formData.get("default1"), "default"); 101 | assertEquals(formData.get("default2"), "request"); 102 | assertEquals(formData.get("default3"), "override"); 103 | assertEquals(formData.get("request1"), "request"); 104 | assertEquals(formData.get("request2"), "override"); 105 | assertEquals(formData.get("override1"), "override"); 106 | 107 | assertEquals( 108 | req.headers.get("Content-Type"), 109 | "application/x-www-form-urlencoded", 110 | ); 111 | assertEquals(req.headers.get("default-header1"), "default"); 112 | assertEquals(req.headers.get("default-header2"), "request"); 113 | assertEquals(req.headers.get("default-header3"), "override"); 114 | assertEquals(req.headers.get("request-header1"), "request"); 115 | assertEquals(req.headers.get("request-header2"), "override"); 116 | assertEquals(req.headers.get("override-header1"), "override"); 117 | 118 | assertEquals(req.method, "DELETE"); 119 | 120 | assertEquals( 121 | req.url, 122 | [ 123 | "https://auth.server/req?", 124 | "defaultParam1=default&defaultParam2=request&defaultParam3=override&", 125 | "requestParam1=request&requestParam2=override&", 126 | "overrideParam1=override", 127 | ].join(""), 128 | ); 129 | }); 130 | -------------------------------------------------------------------------------- /src/implicit_grant.ts: -------------------------------------------------------------------------------- 1 | import type { OAuth2Client } from "./oauth2_client.ts"; 2 | import { OAuth2GrantBase } from "./grant_base.ts"; 3 | import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts"; 4 | import type { RequestOptions, Tokens } from "./types.ts"; 5 | 6 | export interface ImplicitUriOptions { 7 | /** 8 | * State parameter to send along with the authorization request. 9 | * 10 | * see https://tools.ietf.org/html/rfc6749#section-4.2.1 11 | */ 12 | state?: string; 13 | /** 14 | * Scopes to request with the authorization request. 15 | * 16 | * If an array is passed, it is concatenated using spaces as per 17 | * https://tools.ietf.org/html/rfc6749#section-3.3 18 | */ 19 | scope?: string | string[]; 20 | } 21 | 22 | export interface ImplicitTokenOptions { 23 | /** 24 | * The state parameter expected to be returned by the authorization response. 25 | * 26 | * Usually you'd store the state you sent with the authorization request in the 27 | * user's session so you can pass it here. 28 | * If it could be one of many states or you want to run some custom verification 29 | * logic, use the `stateValidator` parameter instead. 30 | */ 31 | state?: string; 32 | /** 33 | * The state validator used to verify that the received state is valid. 34 | * 35 | * The option object's state value is ignored when a stateValidator is passed. 36 | */ 37 | stateValidator?: (state: string | null) => Promise | boolean; 38 | /** Request options used when making the access token request. */ 39 | requestOptions?: RequestOptions; 40 | } 41 | 42 | export class ImplicitGrant extends OAuth2GrantBase { 43 | constructor(client: OAuth2Client) { 44 | super(client); 45 | } 46 | 47 | /** Builds a URI you can redirect a user to to make the authorization request. */ 48 | public getAuthorizationUri(options: ImplicitUriOptions = {}): URL { 49 | const params = new URLSearchParams(); 50 | params.set("response_type", "token"); 51 | params.set("client_id", this.client.config.clientId); 52 | if (typeof this.client.config.redirectUri === "string") { 53 | params.set("redirect_uri", this.client.config.redirectUri); 54 | } 55 | const scope = options.scope ?? this.client.config.defaults?.scope; 56 | if (scope) { 57 | params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope); 58 | } 59 | if (options.state) { 60 | params.set("state", options.state); 61 | } 62 | return new URL(`?${params}`, this.client.config.authorizationEndpointUri); 63 | } 64 | 65 | /** 66 | * Parses the authorization response request tokens from the authorization server. 67 | * 68 | * Usually you'd want to call this method in the function that handles the user's request to your configured redirectUri. 69 | * @param authResponseUri The complete URI the user got redirected to by the authorization server after making the authorization request. 70 | * Must include the fragment (sometimes also called "hash") of the URL. 71 | */ 72 | public async getToken( 73 | authResponseUri: string | URL, 74 | options: ImplicitTokenOptions = {}, 75 | ): Promise { 76 | const url = authResponseUri instanceof URL 77 | ? authResponseUri 78 | : new URL(authResponseUri); 79 | 80 | if (typeof this.client.config.redirectUri === "string") { 81 | const expectedUrl = new URL(this.client.config.redirectUri); 82 | 83 | if ( 84 | typeof url.pathname === "string" && 85 | url.pathname !== expectedUrl.pathname 86 | ) { 87 | throw new AuthorizationResponseError( 88 | `redirect path should match configured path, but got: ${url.pathname}`, 89 | ); 90 | } 91 | } 92 | 93 | if (!url.hash || !url.hash.substring(1)) { 94 | throw new AuthorizationResponseError( 95 | `URI does not contain callback fragment parameters: ${url}`, 96 | ); 97 | } 98 | 99 | const params = new URLSearchParams(url.hash.substring(1)); 100 | 101 | if (params.get("error") !== null) { 102 | throw OAuth2ResponseError.fromURLSearchParams(params); 103 | } 104 | 105 | const accessToken = params.get("access_token"); 106 | if (!accessToken) { 107 | throw new AuthorizationResponseError("missing access_token"); 108 | } 109 | 110 | const tokenType = params.get("token_type"); 111 | if (!tokenType) { 112 | throw new AuthorizationResponseError("missing token_type"); 113 | } 114 | 115 | const state = params.get("state"); 116 | const stateValidator = options.stateValidator || 117 | (options.state && ((s) => s === options.state)) || 118 | this.client.config.defaults?.stateValidator; 119 | 120 | const tokens: Tokens = { 121 | accessToken, 122 | tokenType, 123 | }; 124 | 125 | const expiresInRaw = params.get("expires_in"); 126 | if (expiresInRaw) { 127 | if (!expiresInRaw.match(/^\d+$/)) { 128 | throw new AuthorizationResponseError("expires_in is not a number"); 129 | } 130 | tokens.expiresIn = parseInt(expiresInRaw, 10); 131 | } 132 | 133 | if (stateValidator && !await stateValidator(state)) { 134 | if (state === null) { 135 | throw new AuthorizationResponseError("missing state"); 136 | } else { 137 | throw new AuthorizationResponseError( 138 | `invalid state: ${params.get("state")}`, 139 | ); 140 | } 141 | } 142 | 143 | const scope = params.get("scope"); 144 | if (scope) { 145 | tokens.scope = scope.split(" "); 146 | } 147 | 148 | return tokens; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/implicit_grant_test.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { assertEquals, assertRejects } from "@std/assert"; 3 | import { 4 | assertSpyCall, 5 | assertSpyCallAsync, 6 | assertSpyCalls, 7 | spy, 8 | } from "@std/testing/mock"; 9 | 10 | import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts"; 11 | import { 12 | assertMatchesUrl, 13 | buildImplicitAccessTokenCallback, 14 | getOAuth2Client, 15 | } from "./test_utils.ts"; 16 | 17 | //#region ImplicitGrant.getAuthorizationUri successful paths 18 | 19 | Deno.test("ImplicitGrant.getAuthorizationUri works without additional options", () => { 20 | assertMatchesUrl( 21 | getOAuth2Client().implicit.getAuthorizationUri(), 22 | "https://auth.server/auth?response_type=token&client_id=clientId", 23 | ); 24 | }); 25 | 26 | Deno.test("ImplicitGrant.getAuthorizationUri when passing a single scope", () => { 27 | assertMatchesUrl( 28 | getOAuth2Client().implicit.getAuthorizationUri({ 29 | scope: "singleScope", 30 | }), 31 | "https://auth.server/auth?response_type=token&client_id=clientId&scope=singleScope", 32 | ); 33 | }); 34 | 35 | Deno.test("ImplicitGrant.getAuthorizationUri when passing multiple scopes", () => { 36 | assertMatchesUrl( 37 | getOAuth2Client().implicit.getAuthorizationUri({ 38 | scope: ["multiple", "scopes"], 39 | }), 40 | "https://auth.server/auth?response_type=token&client_id=clientId&scope=multiple+scopes", 41 | ); 42 | }); 43 | 44 | Deno.test("ImplicitGrant.getAuthorizationUri works when passing a state parameter", () => { 45 | assertMatchesUrl( 46 | getOAuth2Client().implicit.getAuthorizationUri({ 47 | state: "someState", 48 | }), 49 | "https://auth.server/auth?response_type=token&client_id=clientId&state=someState", 50 | ); 51 | }); 52 | 53 | Deno.test("ImplicitGrant.getAuthorizationUri works with redirectUri", () => { 54 | assertMatchesUrl( 55 | getOAuth2Client({ 56 | redirectUri: "https://example.app/redirect", 57 | }).implicit.getAuthorizationUri(), 58 | "https://auth.server/auth?response_type=token&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect", 59 | ); 60 | }); 61 | 62 | Deno.test("ImplicitGrant.getAuthorizationUri works with redirectUri and a single scope", () => { 63 | assertMatchesUrl( 64 | getOAuth2Client({ 65 | redirectUri: "https://example.app/redirect", 66 | }).implicit.getAuthorizationUri({ 67 | scope: "singleScope", 68 | }), 69 | "https://auth.server/auth?response_type=token&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=singleScope", 70 | ); 71 | }); 72 | 73 | Deno.test("ImplicitGrant.getAuthorizationUri works with redirectUri and multiple scopes", () => { 74 | assertMatchesUrl( 75 | getOAuth2Client({ 76 | redirectUri: "https://example.app/redirect", 77 | }).implicit.getAuthorizationUri({ 78 | scope: ["multiple", "scopes"], 79 | }), 80 | "https://auth.server/auth?response_type=token&client_id=clientId&redirect_uri=https%3A%2F%2Fexample.app%2Fredirect&scope=multiple+scopes", 81 | ); 82 | }); 83 | 84 | Deno.test("ImplicitGrant.getAuthorizationUri uses default scopes if no scope was specified", () => { 85 | assertMatchesUrl( 86 | getOAuth2Client({ 87 | defaults: { scope: ["default", "scopes"] }, 88 | }).implicit.getAuthorizationUri(), 89 | "https://auth.server/auth?response_type=token&client_id=clientId&scope=default+scopes", 90 | ); 91 | }); 92 | 93 | Deno.test("ImplicitGrant.getAuthorizationUri uses specified scopes over default scopes", () => { 94 | assertMatchesUrl( 95 | getOAuth2Client({ 96 | defaults: { scope: ["default", "scopes"] }, 97 | }).implicit.getAuthorizationUri({ 98 | scope: "notDefault", 99 | }), 100 | "https://auth.server/auth?response_type=token&client_id=clientId&scope=notDefault", 101 | ); 102 | }); 103 | 104 | //#endregion 105 | 106 | //#region ImplicitGrant.getToken error paths 107 | 108 | Deno.test("ImplicitGrant.getToken throws if the received redirectUri does not match the configured one", async () => { 109 | await assertRejects( 110 | () => 111 | getOAuth2Client({ 112 | redirectUri: "https://example.com/redirect", 113 | }).implicit.getToken( 114 | buildImplicitAccessTokenCallback({ 115 | baseUrl: "https://example.com/invalid-redirect", 116 | }), 117 | ), 118 | AuthorizationResponseError, 119 | "redirect path should match configured path", 120 | ); 121 | }); 122 | 123 | Deno.test("ImplicitGrant.getToken throws if the callbackUri does not contain any parameters", async () => { 124 | await assertRejects( 125 | () => 126 | getOAuth2Client().implicit.getToken( 127 | buildImplicitAccessTokenCallback(), 128 | ), 129 | AuthorizationResponseError, 130 | "URI does not contain callback fragment parameters", 131 | ); 132 | }); 133 | 134 | Deno.test("ImplicitGrant.getToken throws if the callbackUri contains an error parameter", async () => { 135 | await assertRejects( 136 | () => 137 | getOAuth2Client().implicit.getToken( 138 | buildImplicitAccessTokenCallback({ 139 | params: { error: "invalid_request" }, 140 | }), 141 | ), 142 | OAuth2ResponseError, 143 | "invalid_request", 144 | ); 145 | }); 146 | 147 | Deno.test("ImplicitGrant.getToken throws if the callbackUri contains the error, error_description and error_uri parameters and adds them to the error object", async () => { 148 | const error = await assertRejects( 149 | () => 150 | getOAuth2Client().implicit.getToken( 151 | buildImplicitAccessTokenCallback({ 152 | params: { 153 | error: "invalid_request", 154 | error_description: "Error description", 155 | error_uri: "error://uri", 156 | }, 157 | }), 158 | ), 159 | OAuth2ResponseError, 160 | "Error description", 161 | ) as OAuth2ResponseError; 162 | assertEquals(error.error, "invalid_request"); 163 | assertEquals(error.errorDescription, "Error description"); 164 | assertEquals(error.errorUri, "error://uri"); 165 | }); 166 | 167 | Deno.test("ImplicitGrant.getToken throws if the callbackUri doesn't contain an access_token", async () => { 168 | await assertRejects( 169 | () => 170 | getOAuth2Client().implicit.getToken( 171 | buildImplicitAccessTokenCallback({ 172 | // some parameter has to be set or we'll get "URI does not contain callback parameters" instead 173 | params: { empty: "" } as any, 174 | }), 175 | ), 176 | AuthorizationResponseError, 177 | "missing access_token", 178 | ); 179 | }); 180 | 181 | Deno.test("ImplicitGrant.getToken throws if it didn't receive an access_token", async () => { 182 | await assertRejects( 183 | () => 184 | getOAuth2Client().implicit.getToken( 185 | buildImplicitAccessTokenCallback({ 186 | params: { token_type: "Bearer" }, 187 | }), 188 | ), 189 | AuthorizationResponseError, 190 | "missing access_token", 191 | ); 192 | }); 193 | 194 | Deno.test("ImplicitGrant.getToken throws if it didn't receive a token_type", async () => { 195 | await assertRejects( 196 | () => 197 | getOAuth2Client().implicit.getToken( 198 | buildImplicitAccessTokenCallback({ 199 | params: { access_token: "token" }, 200 | }), 201 | ), 202 | AuthorizationResponseError, 203 | "missing token_type", 204 | ); 205 | }); 206 | 207 | Deno.test("ImplicitGrant.getToken throws if it didn't receive a state and the state validator fails", async () => { 208 | await assertRejects( 209 | () => 210 | getOAuth2Client().implicit.getToken( 211 | buildImplicitAccessTokenCallback({ 212 | params: { access_token: "at", token_type: "Bearer" }, 213 | }), 214 | { stateValidator: () => false }, 215 | ), 216 | AuthorizationResponseError, 217 | "missing state", 218 | ); 219 | }); 220 | 221 | Deno.test("ImplicitGrant.getToken throws if it didn't receive a state and the async state validator fails", async () => { 222 | await assertRejects( 223 | () => 224 | getOAuth2Client().implicit.getToken( 225 | buildImplicitAccessTokenCallback({ 226 | params: { access_token: "at", token_type: "Bearer" }, 227 | }), 228 | { stateValidator: () => Promise.resolve(false) }, 229 | ), 230 | AuthorizationResponseError, 231 | "missing state", 232 | ); 233 | }); 234 | 235 | Deno.test("ImplicitGrant.getToken throws if it didn't receive a state and the default async state validator fails", async () => { 236 | await assertRejects( 237 | () => 238 | getOAuth2Client({ 239 | defaults: { stateValidator: () => Promise.resolve(false) }, 240 | }).implicit.getToken( 241 | buildImplicitAccessTokenCallback({ 242 | params: { access_token: "at", token_type: "Bearer" }, 243 | }), 244 | ), 245 | AuthorizationResponseError, 246 | "missing state", 247 | ); 248 | }); 249 | 250 | Deno.test("ImplicitGrant.getToken throws if it didn't receive a state but a state was expected", async () => { 251 | await assertRejects( 252 | () => 253 | getOAuth2Client().implicit.getToken( 254 | buildImplicitAccessTokenCallback({ 255 | params: { access_token: "at", token_type: "Bearer" }, 256 | }), 257 | { state: "expected_state" }, 258 | ), 259 | AuthorizationResponseError, 260 | "missing state", 261 | ); 262 | }); 263 | 264 | Deno.test("ImplicitGrant.getToken throws if it received a state that does not match the given state parameter", async () => { 265 | await assertRejects( 266 | () => 267 | getOAuth2Client().implicit.getToken( 268 | buildImplicitAccessTokenCallback({ 269 | params: { 270 | access_token: "at", 271 | token_type: "Bearer", 272 | state: "invalid_state", 273 | }, 274 | }), 275 | { state: "expected_state" }, 276 | ), 277 | AuthorizationResponseError, 278 | "invalid state: invalid_state", 279 | ); 280 | }); 281 | 282 | Deno.test("ImplicitGrant.getToken throws if the stateValidator returns false", async () => { 283 | await assertRejects( 284 | () => 285 | getOAuth2Client().implicit.getToken( 286 | buildImplicitAccessTokenCallback({ 287 | params: { 288 | access_token: "at", 289 | token_type: "Bearer", 290 | state: "invalid_state", 291 | }, 292 | }), 293 | { stateValidator: () => false }, 294 | ), 295 | AuthorizationResponseError, 296 | "invalid state: invalid_state", 297 | ); 298 | }); 299 | 300 | Deno.test("ImplicitGrant.getToken throws if the async stateValidator returns false", async () => { 301 | await assertRejects( 302 | () => 303 | getOAuth2Client().implicit.getToken( 304 | buildImplicitAccessTokenCallback({ 305 | params: { 306 | access_token: "at", 307 | token_type: "Bearer", 308 | state: "invalid_state", 309 | }, 310 | }), 311 | { stateValidator: () => Promise.resolve(false) }, 312 | ), 313 | AuthorizationResponseError, 314 | "invalid state: invalid_state", 315 | ); 316 | }); 317 | 318 | Deno.test("ImplicitGrant.getToken throws if the server response's expires_in property is not a number", async () => { 319 | await assertRejects( 320 | () => 321 | getOAuth2Client().implicit.getToken(buildImplicitAccessTokenCallback({ 322 | params: { 323 | access_token: "at", 324 | token_type: "Bearer", 325 | expires_in: "invalid", 326 | }, 327 | })), 328 | AuthorizationResponseError, 329 | "expires_in is not a number", 330 | ); 331 | }); 332 | 333 | //#endregion 334 | 335 | //#region ImplicitGrant.getToken successful paths 336 | 337 | Deno.test("ImplicitGrant.getToken parses the minimal token response correctly", async () => { 338 | const result = await getOAuth2Client().implicit.getToken( 339 | buildImplicitAccessTokenCallback({ 340 | params: { access_token: "accessToken", token_type: "tokenType" }, 341 | }), 342 | ); 343 | assertEquals(result, { 344 | accessToken: "accessToken", 345 | tokenType: "tokenType", 346 | }); 347 | }); 348 | 349 | Deno.test("ImplicitGrant.getToken parses the full token response correctly", async () => { 350 | const result = await getOAuth2Client().implicit.getToken( 351 | buildImplicitAccessTokenCallback({ 352 | params: { 353 | access_token: "accessToken", 354 | token_type: "tokenType", 355 | expires_in: "3600", 356 | scope: "multiple scopes", 357 | }, 358 | }), 359 | ); 360 | assertEquals(result, { 361 | accessToken: "accessToken", 362 | tokenType: "tokenType", 363 | expiresIn: 3600, 364 | scope: ["multiple", "scopes"], 365 | }); 366 | }); 367 | 368 | Deno.test("ImplicitGrant.getToken doesn't throw if it didn't receive a state but the state validator returns true", async () => { 369 | await getOAuth2Client().implicit.getToken( 370 | buildImplicitAccessTokenCallback({ 371 | params: { access_token: "accessToken", token_type: "tokenType" }, 372 | }), 373 | { stateValidator: () => true }, 374 | ); 375 | }); 376 | 377 | Deno.test("ImplicitGrant.getToken doesn't throw if it didn't receive a state but the async state validator returns true", async () => { 378 | await getOAuth2Client().implicit.getToken( 379 | buildImplicitAccessTokenCallback({ 380 | params: { access_token: "accessToken", token_type: "tokenType" }, 381 | }), 382 | { stateValidator: () => Promise.resolve(true) }, 383 | ); 384 | }); 385 | 386 | Deno.test("ImplicitGrant.getToken uses the default state validator if no state or validator was given", async () => { 387 | const defaultValidator = spy(() => true); 388 | 389 | await getOAuth2Client({ 390 | defaults: { stateValidator: defaultValidator }, 391 | }).implicit.getToken(buildImplicitAccessTokenCallback({ 392 | params: { 393 | access_token: "accessToken", 394 | token_type: "tokenType", 395 | state: "some_state", 396 | }, 397 | })); 398 | 399 | assertSpyCall(defaultValidator, 0, { args: ["some_state"], returned: true }); 400 | assertSpyCalls(defaultValidator, 1); 401 | }); 402 | 403 | Deno.test("ImplicitGrant.getToken uses the default async state validator if no state or validator was given", async () => { 404 | const defaultValidator = spy(() => Promise.resolve(true)); 405 | 406 | await getOAuth2Client({ 407 | defaults: { stateValidator: defaultValidator }, 408 | }).implicit.getToken(buildImplicitAccessTokenCallback({ 409 | params: { 410 | access_token: "accessToken", 411 | token_type: "tokenType", 412 | state: "some_state", 413 | }, 414 | })); 415 | 416 | assertSpyCallAsync(defaultValidator, 0, { 417 | args: ["some_state"], 418 | returned: true, 419 | }); 420 | assertSpyCalls(defaultValidator, 1); 421 | }); 422 | 423 | Deno.test("ImplicitGrant.getToken uses the passed state validator over the default validator", () => { 424 | const defaultValidator = spy(() => true); 425 | const validator = spy(() => true); 426 | 427 | getOAuth2Client({ 428 | defaults: { stateValidator: defaultValidator }, 429 | }).implicit.getToken( 430 | buildImplicitAccessTokenCallback({ 431 | params: { 432 | access_token: "accessToken", 433 | token_type: "tokenType", 434 | state: "some_state", 435 | }, 436 | }), 437 | { stateValidator: validator }, 438 | ); 439 | 440 | assertSpyCalls(defaultValidator, 0); 441 | assertSpyCall(validator, 0, { args: ["some_state"], returned: true }); 442 | assertSpyCalls(validator, 1); 443 | }); 444 | 445 | Deno.test("ImplicitGrant.getToken uses the passed async state validator over the default validator", () => { 446 | const defaultValidator = spy(() => true); 447 | const validator = spy(() => Promise.resolve(true)); 448 | 449 | getOAuth2Client({ 450 | defaults: { stateValidator: defaultValidator }, 451 | }).implicit.getToken( 452 | buildImplicitAccessTokenCallback({ 453 | params: { 454 | access_token: "accessToken", 455 | token_type: "tokenType", 456 | state: "some_state", 457 | }, 458 | }), 459 | { stateValidator: validator }, 460 | ); 461 | 462 | assertSpyCalls(defaultValidator, 0); 463 | assertSpyCallAsync(validator, 0, { args: ["some_state"], returned: true }); 464 | assertSpyCalls(validator, 1); 465 | }); 466 | 467 | Deno.test("ImplicitGrant.getToken uses the passed state validator over the passed state", () => { 468 | const defaultValidator = spy(() => true); 469 | const validator = spy(() => true); 470 | 471 | getOAuth2Client({ 472 | defaults: { stateValidator: defaultValidator }, 473 | }).implicit.getToken( 474 | buildImplicitAccessTokenCallback({ 475 | params: { 476 | access_token: "accessToken", 477 | token_type: "tokenType", 478 | state: "some_state", 479 | }, 480 | }), 481 | { stateValidator: validator, state: "other_state" }, 482 | ); 483 | 484 | assertSpyCalls(defaultValidator, 0); 485 | assertSpyCall(validator, 0, { args: ["some_state"], returned: true }); 486 | assertSpyCalls(validator, 1); 487 | }); 488 | 489 | //#endregion 490 | -------------------------------------------------------------------------------- /src/oauth2_client.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationCodeGrant } from "./authorization_code_grant.ts"; 2 | import { ClientCredentialsGrant } from "./client_credentials_grant.ts"; 3 | import { ImplicitGrant } from "./implicit_grant.ts"; 4 | import { RefreshTokenGrant } from "./refresh_token_grant.ts"; 5 | import { ResourceOwnerPasswordCredentialsGrant } from "./resource_owner_password_credentials.ts"; 6 | import type { RequestOptions } from "./types.ts"; 7 | 8 | export interface OAuth2ClientConfig { 9 | /** The client ID provided by the authorization server. */ 10 | clientId: string; 11 | /** The client secret provided by the authorization server, if using a confidential client. */ 12 | clientSecret?: string; 13 | /** The URI of the client's redirection endpoint (sometimes also called callback URI). */ 14 | redirectUri?: string; 15 | 16 | /** The URI of the authorization server's authorization endpoint. */ 17 | authorizationEndpointUri: string; 18 | /** The URI of the authorization server's token endpoint. */ 19 | tokenUri: string; 20 | 21 | defaults?: { 22 | /** 23 | * Default request options to use when performing outgoing HTTP requests. 24 | * 25 | * For example used when exchanging authorization codes for access tokens. 26 | */ 27 | requestOptions?: Omit; 28 | /** Default scopes to request unless otherwise specified. */ 29 | scope?: string | string[]; 30 | /** Default state validator to use for validating the authorization response's state value. */ 31 | stateValidator?: (state: string | null) => Promise | boolean; 32 | }; 33 | } 34 | 35 | export class OAuth2Client { 36 | /** 37 | * Implements the Authorization Code Grant. 38 | * 39 | * See RFC6749, section 4.1. 40 | */ 41 | public code: AuthorizationCodeGrant = new AuthorizationCodeGrant(this); 42 | 43 | /** 44 | * Implements the Implicit Grant. 45 | * 46 | * See RFC6749, section 4.2. 47 | */ 48 | public implicit: ImplicitGrant = new ImplicitGrant(this); 49 | 50 | /** 51 | * Implements the Resource Owner Password Credentials Grant. 52 | * 53 | * See RFC6749, section 4.3. 54 | */ 55 | public ropc: ResourceOwnerPasswordCredentialsGrant = 56 | new ResourceOwnerPasswordCredentialsGrant(this); 57 | 58 | /** 59 | * Implements the Resource Owner Password Credentials Grant. 60 | * 61 | * See RFC6749, section 4.4. 62 | */ 63 | public clientCredentials: ClientCredentialsGrant = new ClientCredentialsGrant( 64 | this, 65 | ); 66 | 67 | /** 68 | * Implements the Refresh Token Grant. 69 | * 70 | * See RFC6749, section 6. 71 | */ 72 | public refreshToken: RefreshTokenGrant = new RefreshTokenGrant(this); 73 | 74 | constructor( 75 | public readonly config: Readonly, 76 | ) {} 77 | } 78 | -------------------------------------------------------------------------------- /src/oauth2_client_test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "@std/assert/assert"; 2 | 3 | import { OAuth2Client } from "./oauth2_client.ts"; 4 | import { AuthorizationCodeGrant } from "./authorization_code_grant.ts"; 5 | import { RefreshTokenGrant } from "./refresh_token_grant.ts"; 6 | 7 | Deno.test("OAuth2Client.code is created", () => { 8 | const client = new OAuth2Client({ 9 | tokenUri: "", 10 | authorizationEndpointUri: "", 11 | clientId: "", 12 | }); 13 | assert(client.code instanceof AuthorizationCodeGrant); 14 | }); 15 | 16 | Deno.test("OAuth2Client.refreshToken is created", () => { 17 | const client = new OAuth2Client({ 18 | tokenUri: "", 19 | authorizationEndpointUri: "", 20 | clientId: "", 21 | }); 22 | assert(client.refreshToken instanceof RefreshTokenGrant); 23 | }); 24 | -------------------------------------------------------------------------------- /src/pkce.ts: -------------------------------------------------------------------------------- 1 | import { encodeBase64 } from "@std/encoding/base64"; 2 | 3 | export interface PkceChallenge { 4 | codeVerifier: string; 5 | codeChallenge: string; 6 | codeChallengeMethod: string; 7 | } 8 | 9 | /** 10 | * Encodes the data as a URL-safe variant of Base64 11 | * in accordance with https://www.rfc-editor.org/rfc/rfc7636#appendix-A 12 | */ 13 | function encodeUrlSafe(data: string | ArrayBuffer): string { 14 | return encodeBase64(data) 15 | .replace(/=/g, "") 16 | .replace(/\+/g, "-") 17 | .replace(/\//g, "_"); 18 | } 19 | 20 | /** Calculates the SHA256 hash of the given string */ 21 | async function sha256(str: string): Promise { 22 | const bytes = new TextEncoder().encode(str); 23 | const hash = await crypto.subtle.digest("SHA-256", bytes); 24 | return hash; 25 | } 26 | 27 | /** Returns a byte array of the given length, filled with random numbers */ 28 | function getRandomBytes(length: number): ArrayBuffer { 29 | const randomBytes = new Uint8Array(length); 30 | crypto.getRandomValues(randomBytes); 31 | return randomBytes; 32 | } 33 | 34 | /** 35 | * Creates a codeChallenge and codeVerifier in accordance with the 36 | * Proof Key for Code Exchange (PKCE) {@link https://www.rfc-editor.org/rfc/rfc7636 RFC7636} 37 | * 38 | * See {@link https://www.rfc-editor.org/rfc/rfc7636#section-4 RFC7636 Section 4} 39 | * for a step-by-step explanation of what's happening here. 40 | */ 41 | export async function createPkceChallenge(): Promise { 42 | const randomBytes = _internals.getRandomBytes(32); 43 | const codeVerifier = encodeUrlSafe(randomBytes); 44 | 45 | const hash = await sha256(codeVerifier); 46 | const codeChallenge = encodeUrlSafe(hash); 47 | 48 | return { 49 | codeVerifier, 50 | codeChallenge, 51 | codeChallengeMethod: "S256", 52 | }; 53 | } 54 | 55 | /** @deprecated This should only ever be used for testing */ 56 | export const _internals = { getRandomBytes }; 57 | -------------------------------------------------------------------------------- /src/pkce_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertMatch } from "@std/assert"; 2 | import { returnsNext, stub } from "@std/testing/mock"; 3 | 4 | import { _internals as pkceInternals, createPkceChallenge } from "./pkce.ts"; 5 | 6 | /** 7 | * Returns the byte array from the example in https://www.rfc-editor.org/rfc/rfc7636#appendix-B 8 | */ 9 | function getExampleBytes(): Uint8Array { 10 | return new Uint8Array([ 11 | 116, 12 | 24, 13 | 223, 14 | 180, 15 | 151, 16 | 153, 17 | 224, 18 | 37, 19 | 79, 20 | 250, 21 | 96, 22 | 125, 23 | 216, 24 | 173, 25 | 187, 26 | 186, 27 | 22, 28 | 212, 29 | 37, 30 | 77, 31 | 105, 32 | 214, 33 | 191, 34 | 240, 35 | 91, 36 | 88, 37 | 5, 38 | 88, 39 | 83, 40 | 132, 41 | 141, 42 | 121, 43 | ]); 44 | } 45 | 46 | Deno.test("createPkceChallenge correctly builds a codeChallenge and codeVerifier", async () => { 47 | const getRandomBytesStub = stub( 48 | pkceInternals, 49 | "getRandomBytes", 50 | returnsNext([getExampleBytes()]), 51 | ); 52 | try { 53 | const { codeVerifier, codeChallenge, codeChallengeMethod } = 54 | await createPkceChallenge(); 55 | 56 | assertEquals(codeChallengeMethod, "S256"); 57 | assertEquals(codeVerifier, "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); 58 | assertEquals(codeChallenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 59 | } finally { 60 | getRandomBytesStub.restore(); 61 | } 62 | }); 63 | 64 | Deno.test("createPkceChallenge returns random base64url encoded codes", async () => { 65 | const urlBase64Regex = /^[a-z0-9_-]{43}$/i; 66 | 67 | const verifiers = new Set(); 68 | const challenges = new Set(); 69 | 70 | for (let i = 0; i < 1000; i++) { 71 | const { codeVerifier, codeChallenge } = await createPkceChallenge(); 72 | 73 | assertEquals(verifiers.has(codeVerifier), false); 74 | assertEquals(challenges.has(codeChallenge), false); 75 | verifiers.add(codeVerifier); 76 | verifiers.add(codeChallenge); 77 | 78 | assertMatch(codeVerifier, urlBase64Regex); 79 | assertMatch(codeChallenge, urlBase64Regex); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /src/refresh_token_grant.ts: -------------------------------------------------------------------------------- 1 | import type { RequestOptions, Tokens } from "./types.ts"; 2 | import type { OAuth2Client } from "./oauth2_client.ts"; 3 | import { OAuth2GrantBase } from "./grant_base.ts"; 4 | 5 | export interface RefreshTokenOptions { 6 | scope?: string | string[]; 7 | requestOptions?: RequestOptions; 8 | } 9 | 10 | /** 11 | * Implements the OAuth 2.0 refresh token grant. 12 | * 13 | * See https://tools.ietf.org/html/rfc6749#section-6 14 | */ 15 | export class RefreshTokenGrant extends OAuth2GrantBase { 16 | constructor(client: OAuth2Client) { 17 | super(client); 18 | } 19 | 20 | /** Request new tokens from the authorization server using the given refresh token. */ 21 | async refresh( 22 | refreshToken: string, 23 | options: RefreshTokenOptions = {}, 24 | ): Promise { 25 | const body: Record = { 26 | "grant_type": "refresh_token", 27 | "refresh_token": refreshToken, 28 | }; 29 | 30 | if (typeof (options?.scope) === "string") { 31 | body.scope = options.scope; 32 | } else if (Array.isArray(options?.scope)) { 33 | body.scope = options.scope.join(" "); 34 | } 35 | 36 | const headers: Record = { 37 | "Accept": "application/json", 38 | }; 39 | if (typeof this.client.config.clientSecret === "string") { 40 | // Note: RFC 6749 doesn't really say how a non-confidential client should 41 | // prove its identity when making a refresh token request, so we just don't 42 | // do anything and let the user deal with that (e.g. using the requestOptions) 43 | const { clientId, clientSecret } = this.client.config; 44 | headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; 45 | } 46 | 47 | const req = this.buildRequest(this.client.config.tokenUri, { 48 | method: "POST", 49 | body, 50 | headers, 51 | }, options.requestOptions); 52 | 53 | const accessTokenResponse = await fetch(req); 54 | 55 | return this.parseTokenResponse(accessTokenResponse); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/refresh_token_grant_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert/equals"; 2 | import { getOAuth2Client, mockATResponse } from "./test_utils.ts"; 3 | 4 | Deno.test("RefreshTokenGrant.refresh works without optional options", async () => { 5 | const client = getOAuth2Client(); 6 | const { request, result } = await mockATResponse( 7 | () => client.refreshToken.refresh("refreshToken"), 8 | { 9 | body: { 10 | access_token: "at", 11 | token_type: "tt", 12 | refresh_token: "rt", 13 | expires_in: 1234, 14 | }, 15 | }, 16 | ); 17 | 18 | assertEquals(request.method, "POST"); 19 | assertEquals(request.url, "https://auth.server/token"); 20 | const requestBody = new URLSearchParams(await request.text()); 21 | assertEquals(requestBody.get("grant_type"), "refresh_token"); 22 | assertEquals(requestBody.get("refresh_token"), "refreshToken"); 23 | assertEquals(requestBody.get("scope"), null); 24 | 25 | assertEquals(result.accessToken, "at"); 26 | assertEquals(result.tokenType, "tt"); 27 | assertEquals(result.refreshToken, "rt"); 28 | assertEquals(result.expiresIn, 1234); 29 | }); 30 | 31 | Deno.test("RefreshTokenGrant.refresh works when overriding the HTTP method", async () => { 32 | const client = getOAuth2Client(); 33 | const { request } = await mockATResponse( 34 | () => 35 | client.refreshToken.refresh( 36 | "refreshToken", 37 | { requestOptions: { method: "GET" } }, 38 | ), 39 | { 40 | body: { 41 | access_token: "at", 42 | token_type: "tt", 43 | }, 44 | }, 45 | ); 46 | 47 | assertEquals(request.method, "GET"); 48 | }); 49 | 50 | Deno.test("RefreshTokenGrant.refresh works when requesting a single scope", async () => { 51 | const client = getOAuth2Client(); 52 | const { request } = await mockATResponse( 53 | () => client.refreshToken.refresh("refreshToken", { scope: "test" }), 54 | { 55 | body: { 56 | access_token: "at", 57 | token_type: "tt", 58 | }, 59 | }, 60 | ); 61 | 62 | assertEquals(new URLSearchParams(await request.text()).get("scope"), "test"); 63 | }); 64 | 65 | Deno.test("RefreshTokenGrant.refresh works when requesting multiple scopes", async () => { 66 | const client = getOAuth2Client(); 67 | const { request } = await mockATResponse( 68 | () => 69 | client.refreshToken.refresh( 70 | "refreshToken", 71 | { scope: ["multiple", "scopes"] }, 72 | ), 73 | { 74 | body: { 75 | access_token: "at", 76 | token_type: "tt", 77 | }, 78 | }, 79 | ); 80 | 81 | assertEquals( 82 | new URLSearchParams(await request.text()).get("scope"), 83 | "multiple scopes", 84 | ); 85 | }); 86 | 87 | Deno.test("RefreshTokenGrant.refresh adds the Authorization header when a clientSecret is set", async () => { 88 | const client = getOAuth2Client({ clientSecret: "secret" }); 89 | const { request } = await mockATResponse( 90 | () => client.refreshToken.refresh("refreshToken", { scope: "test" }), 91 | { 92 | body: { 93 | access_token: "at", 94 | token_type: "tt", 95 | }, 96 | }, 97 | ); 98 | 99 | assertEquals( 100 | request.headers.get("Authorization"), 101 | "Basic Y2xpZW50SWQ6c2VjcmV0", 102 | ); 103 | }); 104 | -------------------------------------------------------------------------------- /src/resource_owner_password_credentials.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2GrantBase } from "./grant_base.ts"; 2 | import type { OAuth2Client } from "./oauth2_client.ts"; 3 | import type { RequestOptions, Tokens } from "./types.ts"; 4 | 5 | export interface ResourceOwnerPasswordCredentialsTokenOptions { 6 | /** The resource owner username */ 7 | username: string; 8 | /** The resource owner password */ 9 | password: string; 10 | /** 11 | * Scopes to request with the authorization request. 12 | * 13 | * If an array is passed, it is concatenated using spaces as per 14 | * https://tools.ietf.org/html/rfc6749#section-3.3 15 | */ 16 | scope?: string | string[]; 17 | 18 | requestOptions?: RequestOptions; 19 | } 20 | 21 | /** 22 | * Implements the OAuth 2.0 resource owner password credentials grant. 23 | * 24 | * See https://tools.ietf.org/html/rfc6749#section-4.3 25 | */ 26 | export class ResourceOwnerPasswordCredentialsGrant extends OAuth2GrantBase { 27 | constructor(client: OAuth2Client) { 28 | super(client); 29 | } 30 | 31 | /** 32 | * Uses the username and password to request an access and optional refresh token 33 | */ 34 | public async getToken( 35 | options: ResourceOwnerPasswordCredentialsTokenOptions, 36 | ): Promise { 37 | const request = this.buildTokenRequest(options); 38 | 39 | const accessTokenResponse = await fetch(request); 40 | 41 | return this.parseTokenResponse(accessTokenResponse); 42 | } 43 | 44 | private buildTokenRequest( 45 | options: ResourceOwnerPasswordCredentialsTokenOptions, 46 | ): Request { 47 | const body: Record = { 48 | "grant_type": "password", 49 | username: options.username, 50 | password: options.password, 51 | }; 52 | const headers: Record = { 53 | "Accept": "application/json", 54 | }; 55 | 56 | const scope = options.scope ?? this.client.config.defaults?.scope; 57 | if (scope) { 58 | if (Array.isArray(scope)) { 59 | body.scope = scope.join(" "); 60 | } else { 61 | body.scope = scope; 62 | } 63 | } 64 | 65 | if (typeof this.client.config.clientSecret === "string") { 66 | // We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1. 67 | const { clientId, clientSecret } = this.client.config; 68 | headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`; 69 | } else { 70 | // This appears to be a public client, include the client ID in the body instead 71 | body.client_id = this.client.config.clientId; 72 | } 73 | 74 | return this.buildRequest(this.client.config.tokenUri, { 75 | method: "POST", 76 | headers, 77 | body, 78 | }, options.requestOptions); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/resource_owner_password_credentials_test.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import { 3 | assertEquals, 4 | assertMatch, 5 | assertNotMatch, 6 | assertRejects, 7 | } from "@std/assert"; 8 | 9 | import { OAuth2ResponseError, TokenResponseError } from "./errors.ts"; 10 | import { getOAuth2Client, mockATResponse } from "./test_utils.ts"; 11 | 12 | //#region ResourceOwnerPasswordCredentialsGrant.getToken error paths 13 | 14 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server responded with a Content-Type other than application/json", async () => { 15 | await assertRejects( 16 | () => 17 | mockATResponse( 18 | () => 19 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 20 | { body: "not json" }, 21 | ), 22 | TokenResponseError, 23 | "Invalid token response: Response is not JSON encoded", 24 | ); 25 | }); 26 | 27 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server responded with a correctly formatted error", async () => { 28 | await assertRejects( 29 | () => 30 | mockATResponse( 31 | () => 32 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 33 | { status: 401, body: { error: "invalid_client" } }, 34 | ), 35 | OAuth2ResponseError, 36 | "invalid_client", 37 | ); 38 | }); 39 | 40 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server responded with a 4xx or 5xx and the body doesn't contain an error parameter", async () => { 41 | await assertRejects( 42 | () => 43 | mockATResponse( 44 | () => 45 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 46 | { status: 401, body: {} }, 47 | ), 48 | TokenResponseError, 49 | "Invalid token response: Server returned 401 and no error description was given", 50 | ); 51 | }); 52 | 53 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server's response is not a JSON object", async () => { 54 | await assertRejects( 55 | () => 56 | mockATResponse( 57 | () => 58 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 59 | { body: '""' }, 60 | ), 61 | TokenResponseError, 62 | "Invalid token response: body is not a JSON object", 63 | ); 64 | await assertRejects( 65 | () => 66 | mockATResponse( 67 | () => 68 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 69 | { body: '["array values?!!"]' }, 70 | ), 71 | TokenResponseError, 72 | "Invalid token response: body is not a JSON object", 73 | ); 74 | }); 75 | 76 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server's response does not contain a token_type", async () => { 77 | await assertRejects( 78 | () => 79 | mockATResponse( 80 | () => 81 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 82 | { body: { access_token: "at" } }, 83 | ), 84 | TokenResponseError, 85 | "Invalid token response: missing token_type", 86 | ); 87 | }); 88 | 89 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server response's token_type is not a string", async () => { 90 | await assertRejects( 91 | () => 92 | mockATResponse( 93 | () => 94 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 95 | { 96 | body: { 97 | access_token: "at", 98 | token_type: 1337 as any, 99 | }, 100 | }, 101 | ), 102 | TokenResponseError, 103 | "Invalid token response: token_type is not a string", 104 | ); 105 | }); 106 | 107 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server's response does not contain an access_token", async () => { 108 | await assertRejects( 109 | () => 110 | mockATResponse( 111 | () => 112 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 113 | { body: { token_type: "tt" } }, 114 | ), 115 | TokenResponseError, 116 | "Invalid token response: missing access_token", 117 | ); 118 | }); 119 | 120 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server response's access_token is not a string", async () => { 121 | await assertRejects( 122 | () => 123 | mockATResponse( 124 | () => 125 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 126 | { 127 | body: { 128 | access_token: 1234 as any, 129 | token_type: "tt", 130 | }, 131 | }, 132 | ), 133 | TokenResponseError, 134 | "Invalid token response: access_token is not a string", 135 | ); 136 | }); 137 | 138 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server response's refresh_token property is present but not a string", async () => { 139 | await assertRejects( 140 | () => 141 | mockATResponse( 142 | () => 143 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 144 | { 145 | body: { 146 | access_token: "at", 147 | token_type: "tt", 148 | refresh_token: 123 as any, 149 | }, 150 | }, 151 | ), 152 | TokenResponseError, 153 | "Invalid token response: refresh_token is not a string", 154 | ); 155 | }); 156 | 157 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server response's expires_in property is present but not a number", async () => { 158 | await assertRejects( 159 | () => 160 | mockATResponse( 161 | () => 162 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 163 | { 164 | body: { 165 | access_token: "at", 166 | token_type: "tt", 167 | expires_in: { this: "is illegal" } as any, 168 | }, 169 | }, 170 | ), 171 | TokenResponseError, 172 | "Invalid token response: expires_in is not a number", 173 | ); 174 | }); 175 | 176 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken throws if the server response's scope property is present but not a string", async () => { 177 | await assertRejects( 178 | () => 179 | mockATResponse( 180 | () => 181 | getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 182 | { 183 | body: { 184 | access_token: "at", 185 | token_type: "tt", 186 | scope: ["scope1", "scope2"] as any, 187 | }, 188 | }, 189 | ), 190 | TokenResponseError, 191 | "Invalid token response: scope is not a string", 192 | ); 193 | }); 194 | 195 | //#endregion 196 | 197 | //#region ResourceOwnerPasswordCredentialsGrant.getToken successful paths 198 | 199 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken builds a correct request to the token endpoint when not setting scopes", async () => { 200 | const { request } = await mockATResponse( 201 | () => 202 | getOAuth2Client().ropc.getToken({ 203 | username: "un", 204 | password: "pw", 205 | }), 206 | ); 207 | 208 | assertEquals(request.url, "https://auth.server/token"); 209 | const body = await request.formData(); 210 | assertEquals(body.get("grant_type"), "password"); 211 | assertEquals(body.get("username"), "un"); 212 | assertEquals(body.get("password"), "pw"); 213 | assertEquals(body.get("client_id"), "clientId"); 214 | assertEquals( 215 | request.headers.get("Content-Type"), 216 | "application/x-www-form-urlencoded", 217 | ); 218 | assertEquals([...body.keys()].length, 4); 219 | }); 220 | 221 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken builds a correct request to the token endpoint when setting a single scope", async () => { 222 | const { request } = await mockATResponse( 223 | () => 224 | getOAuth2Client().ropc.getToken({ 225 | username: "un", 226 | password: "pw", 227 | scope: "singleScope", 228 | }), 229 | ); 230 | 231 | assertEquals(request.url, "https://auth.server/token"); 232 | const body = await request.formData(); 233 | assertEquals(body.get("grant_type"), "password"); 234 | assertEquals(body.get("username"), "un"); 235 | assertEquals(body.get("password"), "pw"); 236 | assertEquals(body.get("client_id"), "clientId"); 237 | assertEquals(body.get("scope"), "singleScope"); 238 | assertEquals( 239 | request.headers.get("Content-Type"), 240 | "application/x-www-form-urlencoded", 241 | ); 242 | assertEquals([...body.keys()].length, 5); 243 | }); 244 | 245 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken builds a correct request to the token endpoint when setting multiple scopes", async () => { 246 | const { request } = await mockATResponse( 247 | () => 248 | getOAuth2Client().ropc.getToken({ 249 | username: "un", 250 | password: "pw", 251 | scope: ["multiple", "scopes"], 252 | }), 253 | ); 254 | 255 | assertEquals(request.url, "https://auth.server/token"); 256 | const body = await request.formData(); 257 | assertEquals(body.get("grant_type"), "password"); 258 | assertEquals(body.get("username"), "un"); 259 | assertEquals(body.get("password"), "pw"); 260 | assertEquals(body.get("client_id"), "clientId"); 261 | assertEquals(body.get("scope"), "multiple scopes"); 262 | assertEquals( 263 | request.headers.get("Content-Type"), 264 | "application/x-www-form-urlencoded", 265 | ); 266 | assertEquals([...body.keys()].length, 5); 267 | }); 268 | 269 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses default scopes if no scope was specified", async () => { 270 | const { request } = await mockATResponse( 271 | () => 272 | getOAuth2Client({ 273 | defaults: { scope: ["default", "scopes"] }, 274 | }).ropc.getToken({ 275 | username: "un", 276 | password: "pw", 277 | }), 278 | ); 279 | 280 | assertEquals(request.url, "https://auth.server/token"); 281 | const body = await request.formData(); 282 | assertEquals(body.get("grant_type"), "password"); 283 | assertEquals(body.get("username"), "un"); 284 | assertEquals(body.get("password"), "pw"); 285 | assertEquals(body.get("client_id"), "clientId"); 286 | assertEquals(body.get("scope"), "default scopes"); 287 | assertEquals( 288 | request.headers.get("Content-Type"), 289 | "application/x-www-form-urlencoded", 290 | ); 291 | assertEquals([...body.keys()].length, 5); 292 | }); 293 | 294 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses specified scopes over default scopes", async () => { 295 | const { request } = await mockATResponse( 296 | () => 297 | getOAuth2Client({ 298 | defaults: { scope: ["default", "scopes"] }, 299 | }).ropc.getToken({ 300 | username: "un", 301 | password: "pw", 302 | scope: "notDefault", 303 | }), 304 | ); 305 | 306 | assertEquals(request.url, "https://auth.server/token"); 307 | const body = await request.formData(); 308 | assertEquals(body.get("grant_type"), "password"); 309 | assertEquals(body.get("username"), "un"); 310 | assertEquals(body.get("password"), "pw"); 311 | assertEquals(body.get("client_id"), "clientId"); 312 | assertEquals(body.get("scope"), "notDefault"); 313 | assertEquals( 314 | request.headers.get("Content-Type"), 315 | "application/x-www-form-urlencoded", 316 | ); 317 | assertEquals([...body.keys()].length, 5); 318 | }); 319 | 320 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken parses the minimal token response correctly", async () => { 321 | const { result } = await mockATResponse( 322 | () => getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 323 | { 324 | body: { 325 | access_token: "accessToken", 326 | token_type: "tokenType", 327 | }, 328 | }, 329 | ); 330 | assertEquals(result, { 331 | accessToken: "accessToken", 332 | tokenType: "tokenType", 333 | }); 334 | }); 335 | 336 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken parses the full token response correctly", async () => { 337 | const { result } = await mockATResponse( 338 | () => getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 339 | { 340 | body: { 341 | access_token: "accessToken", 342 | token_type: "tokenType", 343 | refresh_token: "refreshToken", 344 | expires_in: 3600, 345 | scope: "multiple scopes", 346 | }, 347 | }, 348 | ); 349 | assertEquals(result, { 350 | accessToken: "accessToken", 351 | tokenType: "tokenType", 352 | refreshToken: "refreshToken", 353 | expiresIn: 3600, 354 | scope: ["multiple", "scopes"], 355 | }); 356 | }); 357 | 358 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken sends the clientId as form parameter if no clientSecret is set", async () => { 359 | const { request } = await mockATResponse( 360 | () => getOAuth2Client().ropc.getToken({ username: "un", password: "pw" }), 361 | ); 362 | assertEquals( 363 | (await request.formData()).get("client_id"), 364 | "clientId", 365 | ); 366 | assertEquals(request.headers.get("Authorization"), null); 367 | }); 368 | 369 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken sends the correct Authorization header if the clientSecret is set", async () => { 370 | const { request } = await mockATResponse( 371 | () => 372 | getOAuth2Client({ clientSecret: "super-secret" }).ropc.getToken({ 373 | username: "un", 374 | password: "pw", 375 | }), 376 | ); 377 | assertEquals( 378 | request.headers.get("Authorization"), 379 | "Basic Y2xpZW50SWQ6c3VwZXItc2VjcmV0", 380 | ); 381 | assertEquals((await request.formData()).get("client_id"), null); 382 | }); 383 | 384 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses the default request options", async () => { 385 | const { request } = await mockATResponse( 386 | () => 387 | getOAuth2Client({ 388 | defaults: { 389 | requestOptions: { 390 | headers: { 391 | "User-Agent": "Custom User Agent", 392 | "Content-Type": "application/json", 393 | }, 394 | urlParams: { "custom-url-param": "value" }, 395 | body: { "custom-body-param": "value" }, 396 | }, 397 | }, 398 | }).ropc.getToken({ username: "un", password: "pw" }), 399 | ); 400 | const url = new URL(request.url); 401 | assertEquals(url.searchParams.getAll("custom-url-param"), ["value"]); 402 | assertEquals(request.headers.get("Content-Type"), "application/json"); 403 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 404 | assertMatch(await request.text(), /.*custom-body-param=value.*/); 405 | }); 406 | 407 | Deno.test("ResourceOwnerPasswordCredentialsGrant.getToken uses the passed request options over the default options", async () => { 408 | const { request } = await mockATResponse( 409 | () => 410 | getOAuth2Client({ 411 | defaults: { 412 | requestOptions: { 413 | headers: { 414 | "User-Agent": "Custom User Agent", 415 | "Content-Type": "application/json", 416 | }, 417 | urlParams: { "custom-url-param": "value" }, 418 | body: { "custom-body-param": "value" }, 419 | }, 420 | }, 421 | }).ropc.getToken( 422 | { 423 | username: "un", 424 | password: "pw", 425 | requestOptions: { 426 | headers: { "Content-Type": "text/plain" }, 427 | urlParams: { "custom-url-param": "other_value" }, 428 | body: { "custom-body-param": "other_value" }, 429 | }, 430 | }, 431 | ), 432 | ); 433 | const url = new URL(request.url); 434 | assertEquals(url.searchParams.getAll("custom-url-param"), ["other_value"]); 435 | assertEquals(request.headers.get("Content-Type"), "text/plain"); 436 | assertEquals(request.headers.get("User-Agent"), "Custom User Agent"); 437 | 438 | const requestText = await request.text(); 439 | assertMatch(requestText, /.*custom-body-param=other_value.*/); 440 | assertNotMatch(requestText, /.*custom-body-param=value.*/); 441 | }); 442 | 443 | //#endregion 444 | -------------------------------------------------------------------------------- /src/test_utils.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file camelcase 2 | 3 | import { assertEquals } from "@std/assert/equals"; 4 | import { returnsNext, stub } from "@std/testing/mock"; 5 | import { OAuth2Client, type OAuth2ClientConfig } from "./oauth2_client.ts"; 6 | import type { Tokens } from "./types.ts"; 7 | 8 | export function getOAuth2Client( 9 | overrideConfig: Partial = {}, 10 | ) { 11 | return new OAuth2Client({ 12 | clientId: "clientId", 13 | authorizationEndpointUri: "https://auth.server/auth", 14 | tokenUri: "https://auth.server/token", 15 | ...overrideConfig, 16 | }); 17 | } 18 | 19 | export interface AccessTokenCallbackSuccess { 20 | code?: string; 21 | state?: string; 22 | } 23 | 24 | export interface AccessTokenCallbackError { 25 | error?: string; 26 | "error_description"?: string; 27 | "error_uri"?: string; 28 | state?: string; 29 | } 30 | 31 | interface AccessTokenErrorResponse { 32 | error: string; 33 | "error_description"?: string; 34 | "error_uri"?: string; 35 | } 36 | 37 | interface AccessTokenResponse { 38 | "access_token": string; 39 | "token_type": string; 40 | "expires_in"?: number; 41 | "refresh_token"?: string; 42 | scope?: string; 43 | } 44 | 45 | interface MockAccessTokenResponse { 46 | status?: number; 47 | headers?: { [key: string]: string }; 48 | body?: Partial | string; 49 | } 50 | 51 | interface MockAccessTokenResponseResult { 52 | request: Request; 53 | result: Tokens; 54 | } 55 | 56 | export async function mockATResponse( 57 | request: () => Promise, 58 | response?: MockAccessTokenResponse, 59 | ): Promise { 60 | const body = typeof response?.body === "string" 61 | ? response?.body 62 | : JSON.stringify( 63 | response?.body ?? { access_token: "at", token_type: "tt" }, 64 | ); 65 | 66 | const headers = new Headers( 67 | response?.headers ?? { "Content-Type": "application/json" }, 68 | ); 69 | 70 | const status = response?.status ?? 200; 71 | 72 | const fetchStub = stub( 73 | globalThis, 74 | "fetch", 75 | returnsNext([ 76 | Promise.resolve(new Response(body, { headers, status })), 77 | ]), 78 | ); 79 | try { 80 | const result = await request(); 81 | const req = fetchStub.calls[0].args[0] as Request; 82 | 83 | return { request: req, result }; 84 | } finally { 85 | fetchStub.restore(); 86 | } 87 | } 88 | 89 | interface AccessTokenCallbackOptions { 90 | baseUrl?: string; 91 | params?: AccessTokenCallbackSuccess | AccessTokenCallbackError; 92 | } 93 | 94 | export function buildAccessTokenCallback( 95 | options: AccessTokenCallbackOptions = {}, 96 | ) { 97 | const base = options.baseUrl ?? "https://example.app/callback"; 98 | 99 | const params = new URLSearchParams( 100 | (options.params ?? {}) as Record, 101 | ); 102 | 103 | return new URL(`?${params}`, base); 104 | } 105 | 106 | export interface ImplicitAccessTokenCallbackSuccess { 107 | access_token?: string; 108 | token_type?: string; 109 | expires_in?: string; 110 | scope?: string; 111 | state?: string; 112 | } 113 | 114 | interface ImplicitAccessTokenCallbackOptions { 115 | baseUrl?: string; 116 | params?: ImplicitAccessTokenCallbackSuccess | AccessTokenCallbackError; 117 | } 118 | 119 | export function buildImplicitAccessTokenCallback( 120 | options: ImplicitAccessTokenCallbackOptions = {}, 121 | ) { 122 | const base = options.baseUrl ?? "https://example.app/callback"; 123 | 124 | const params = new URLSearchParams( 125 | (options.params ?? {}) as Record, 126 | ); 127 | 128 | const url = new URL(base); 129 | url.hash = params.toString(); 130 | return url; 131 | } 132 | 133 | export function assertMatchesUrl(test: URL, expectedUrl: string | URL): void { 134 | const expected = expectedUrl instanceof URL 135 | ? expectedUrl 136 | : new URL(expectedUrl); 137 | 138 | assertEquals(test.origin, expected.origin); 139 | assertEquals(test.pathname, expected.pathname); 140 | assertEquals(test.hash, expected.hash); 141 | 142 | const testParams = [...test.searchParams.entries()].sort(([a], [b]) => 143 | a > b ? 1 : a < b ? -1 : 0 144 | ); 145 | const expectedParams = [...expected.searchParams.entries()].sort(([a], [b]) => 146 | a > b ? 1 : a < b ? -1 : 0 147 | ); 148 | assertEquals(testParams, expectedParams); 149 | } 150 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type HttpVerb = 2 | | "GET" 3 | | "HEAD" 4 | | "POST" 5 | | "PUT" 6 | | "DELETE" 7 | | "CONNECT" 8 | | "OPTIONS" 9 | | "TRACE" 10 | | "PATCH"; 11 | 12 | export interface RequestOptions { 13 | /** HTTP Method to use when performing outgoing HTTP requests. */ 14 | method?: HttpVerb; 15 | /** Headers to set when performing outgoing HTTP requests. */ 16 | headers?: Record; 17 | /** Body parameters to set when performing outgoing HTTP requests. */ 18 | body?: Record; 19 | /** URL parameters to set when performing outgoing HTTP requests. */ 20 | urlParams?: Record; 21 | } 22 | 23 | /** Tokens and associated information received from a successful access token request. */ 24 | export interface Tokens { 25 | accessToken: string; 26 | /** 27 | * The type of access token received. 28 | * 29 | * See https://tools.ietf.org/html/rfc6749#section-7.1 30 | * Should usually be "Bearer" for most OAuth 2.0 servers, but don't count on it. 31 | */ 32 | tokenType: string; 33 | /** The lifetime in seconds of the access token. */ 34 | expiresIn?: number; 35 | /** 36 | * The optional refresh token returned by the authorization server. 37 | * 38 | * Consult your OAuth 2.0 Provider's documentation to see under 39 | * which circumstances you'll receive one. 40 | */ 41 | refreshToken?: string; 42 | /** 43 | * The scopes that were granted by the user. 44 | * 45 | * May be undefined if the granted scopes match the requested scopes. 46 | * See https://tools.ietf.org/html/rfc6749#section-5.1 47 | */ 48 | scope?: string[]; 49 | } 50 | --------------------------------------------------------------------------------