Repository: octokit/oauth-app.js (ts)

Issue:
[FEAT]: add `redirectUrl` to constructor option
### Describe the need

Testing OAuth apps locally is hard-ish. Multiple OAuth callback URLs can now be registered for GitHub/OAuth Apps, but when running the server locally, the `redirectUrl` argument has to be passed to `/api/github/oauth/login?redirectUrl=...`.

It would be more elegant if we could pass a default redirectUrl to the OAuthApp constructor instead:

```js
const app = new OAuthApp({
  clientType: "oauth-app",
  clientId: "1234567890abcdef1234",
  clientSecret: "1234567890abcdef1234567890abcdef12345678",
  redirectUrl: process.env.NODE_ENV === "production" ? PRODUCTION_URL : "http://localhost:3000"
});
```

### SDK Version

@octokit/oauth-app@4.1.0

### API Version

n/a

### Relevant log output

```shell
n/a
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

Pull request:
feat: add redirectUrl as a constructor option
<!-- Please refer to our contributing docs for any questions on submitting a pull request -->


<!-- Issues are required for both bug fixes and features. -->
Resolves #367

----

## Behavior

### Before the change?
<!-- Please describe the current behavior that you are modifying. -->

* The redirect url was only able to be set as a login url parameter

### After the change?
<!-- Please describe the behavior or changes that are being added by this PR. -->

* The redirect url now can also be set as a constructor option.
* If both are set, the login url parameter is taken precedence


### Other information
<!-- Any other information that is important to this PR  -->

* None

----

## Additional info

### Pull request checklist
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been reviewed and added / updated if needed (for bug fixes / features)
- [x] Added the appropriate label for the given change

### Does this introduce a breaking change?
<!-- If this introduces a breaking change make sure to note it here any what the impact might be -->

Please see our docs on [breaking changes](https://github.com/octokit/.github/blob/master/community/breaking_changes.md) to help!

- [ ] Yes (Please add the `Type: Breaking change` label)
- [x] No

If `Yes`, what's the impact:  

* N/A


### Pull request type

<!-- Please do not submit updates to dependencies unless it fixes an issue. --> 
<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed. --> 

Please add the corresponding label for change this PR introduces:
- Feature/model/API additions: `Type: Feature`

----



Patch:
diff --git a/README.md b/README.md
index 5c1db44..5c2251c 100644
--- a/README.md
+++ b/README.md
@@ -10,31 +10,33 @@
 
 <!-- toc -->
 
-- [Usage](#usage)
-  - [For OAuth Apps](#for-oauth-apps)
-  - [For GitHub Apps](#for-github-apps)
-- [Examples](#examples)
-- [`OAuthApp.defaults(options)`](#oauthappdefaultsoptions)
-- [Constructor options](#constructor-options)
-- [`app.on(eventName, eventHandler)`](#apponeventname-eventhandler)
-- [`app.octokit`](#appoctokit)
-- [`app.getUserOctokit(options)`](#appgetuseroctokitoptions)
-- [`app.getWebFlowAuthorizationUrl(options)`](#appgetwebflowauthorizationurloptions)
-- [`app.createToken(options)`](#appcreatetokenoptions)
-  - [For OAuth Web flow](#for-oauth-web-flow)
-  - [For OAuth Device flow](#for-oauth-device-flow)
-- [`app.checkToken(options)`](#appchecktokenoptions)
-- [`app.resetToken(options)`](#appresettokenoptions)
-- [`app.refreshToken(options)`](#apprefreshtokenoptions)
-- [`app.scopeToken(options)`](#appscopetokenoptions)
-- [`app.deleteToken(options)`](#appdeletetokenoptions)
-- [`app.deleteAuthorization(options)`](#appdeleteauthorizationoptions)
-- [Middlewares](#middlewares)
-  - [`createNodeMiddleware(app, options)`](#createnodemiddlewareapp-options)
-  - [`createWebWorkerHandler(app, options)`](#createwebworkerhandlerapp-options)
-  - [`createAWSLambdaAPIGatewayV2Handler(app, options)`](#createawslambdaapigatewayv2handlerapp-options)
-- [Contributing](#contributing)
-- [License](#license)
+- [oauth-app.js](#oauth-appjs)
+  - [Usage](#usage)
+    - [For OAuth Apps](#for-oauth-apps)
+    - [For GitHub Apps](#for-github-apps)
+  - [Examples](#examples)
+  - [`OAuthApp.defaults(options)`](#oauthappdefaultsoptions)
+  - [Constructor options](#constructor-options)
+  - [`app.on(eventName, eventHandler)`](#apponeventname-eventhandler)
+  - [`app.octokit`](#appoctokit)
+  - [`app.getUserOctokit(options)`](#appgetuseroctokitoptions)
+  - [`app.getWebFlowAuthorizationUrl(options)`](#appgetwebflowauthorizationurloptions)
+  - [`app.createToken(options)`](#appcreatetokenoptions)
+    - [For OAuth Web flow](#for-oauth-web-flow)
+    - [For OAuth Device flow](#for-oauth-device-flow)
+  - [`app.checkToken(options)`](#appchecktokenoptions)
+  - [`app.resetToken(options)`](#appresettokenoptions)
+  - [`app.refreshToken(options)`](#apprefreshtokenoptions)
+  - [`app.scopeToken(options)`](#appscopetokenoptions)
+  - [`app.deleteToken(options)`](#appdeletetokenoptions)
+  - [`app.deleteAuthorization(options)`](#appdeleteauthorizationoptions)
+  - [Middlewares](#middlewares)
+    - [`createNodeMiddleware(app, options)`](#createnodemiddlewareapp-options)
+    - [`createWebWorkerHandler(app, options)`](#createwebworkerhandlerapp-options)
+    - [`createAWSLambdaAPIGatewayV2Handler(app, options)`](#createawslambdaapigatewayv2handlerapp-options)
+    - [Build Custom Middlewares](#build-custom-middlewares)
+  - [Contributing](#contributing)
+  - [License](#license)
 
 <!-- tocstop -->
 
@@ -188,6 +190,17 @@ const app = new MyOAuthApp({ clientId, clientSecret });
         Sets the default value for <code>app.getWebFlowAuthorizationUrl(options)</code>.
       </td>
     </tr>
+    <tr>
+      <th>
+        <code>redirectUrl</code>
+      </th>
+      <th>
+        <code>string</code>
+      </th>
+      <td>
+        The URL in your application where users will be sent after authorization. See <a href="https://docs.github.com/en/developers/apps/authorizing-oauth-apps#redirect-urls">Redirect URLs</a> in GitHub’s Developer Guide.
+      </td>
+    </tr>
     <tr>
       <th>
         <code>defaultScopes</code>
diff --git a/src/index.ts b/src/index.ts
index 353d4de..131e56f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -112,6 +112,7 @@ export class OAuthApp<
       defaultScopes: options.defaultScopes || [],
       allowSignup: options.allowSignup,
       baseUrl: options.baseUrl,
+      redirectUrl: options.redirectUrl,
       log: options.log,
       Octokit,
       octokit,
diff --git a/src/methods/get-web-flow-authorization-url.ts b/src/methods/get-web-flow-authorization-url.ts
index b103a42..d0dd4a3 100644
--- a/src/methods/get-web-flow-authorization-url.ts
+++ b/src/methods/get-web-flow-authorization-url.ts
@@ -38,6 +38,7 @@ export function getWebFlowAuthorizationUrlWithState(
     request: state.octokit.request,
     ...options,
     allowSignup,
+    redirectUrl: options.redirectUrl || state.redirectUrl,
     scopes: options.scopes || state.defaultScopes,
   };
 
diff --git a/src/types.ts b/src/types.ts
index b31d3b2..8cf2e18 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -36,6 +36,7 @@ type CommonOptions<TOctokit extends OAuthAppOctokitClassType> = {
   clientSecret?: ClientSecret;
   allowSignup?: boolean;
   baseUrl?: string;
+  redirectUrl?: string;
   log?: typeof console;
   Octokit?: TOctokit;
 };
@@ -82,6 +83,7 @@ export type State = {
   defaultScopes: Scope[];
   allowSignup?: boolean;
   baseUrl?: string;
+  redirectUrl?: string;
   log?: typeof console;
   Octokit: OAuthAppOctokitClassType;
   octokit: OctokitInstance;


Test patch:
diff --git a/test/node-middleware.test.ts b/test/node-middleware.test.ts
index d36493a..0d278ce 100644
--- a/test/node-middleware.test.ts
+++ b/test/node-middleware.test.ts
@@ -1080,4 +1080,71 @@ describe("createNodeMiddleware(app)", () => {
     expect(url.searchParams.get("state")).toMatch(/^\w+$/);
     expect(url.searchParams.get("allow_signup")).toEqual("true");
   });
+
+  it("GET /api/github/oauth/login?redirectUrl=http://localhost:12345 with redirectUrl option not set", async () => {
+    const app = new OAuthApp({
+      clientId: "0123",
+      clientSecret: "0123secret",
+    });
+
+    const server = createServer(createNodeMiddleware(app)).listen();
+    // @ts-expect-error complains about { port } although it's included in returned AddressInfo interface
+    const { port } = server.address();
+
+    const { status, headers } = await fetch(
+      `http://localhost:${port}/api/github/oauth/login?redirectUrl=http://localhost:12345`,
+      {
+        redirect: "manual",
+      }
+    );
+
+    server.close();
+
+    expect(status).toEqual(302);
+
+    const url = new URL(headers.get("location") as string);
+    expect(url).toMatchObject({
+      origin: "https://github.com",
+      pathname: "/login/oauth/authorize",
+    });
+    expect(url.searchParams.get("client_id")).toEqual("0123");
+    expect(url.searchParams.get("state")).toMatch(/^\w+$/);
+    expect(url.searchParams.get("redirect_uri")).toEqual(
+      "http://localhost:12345"
+    );
+  });
+
+  it("GET /api/github/oauth/login with redirectUrl option set to http://localhost:1234", async () => {
+    const app = new OAuthApp({
+      clientId: "0123",
+      clientSecret: "0123secret",
+      redirectUrl: "http://localhost:12345",
+    });
+
+    const server = createServer(createNodeMiddleware(app)).listen();
+    // @ts-expect-error complains about { port } although it's included in returned AddressInfo interface
+    const { port } = server.address();
+
+    const { status, headers } = await fetch(
+      `http://localhost:${port}/api/github/oauth/login`,
+      {
+        redirect: "manual",
+      }
+    );
+
+    server.close();
+
+    expect(status).toEqual(302);
+
+    const url = new URL(headers.get("location") as string);
+    expect(url).toMatchObject({
+      origin: "https://github.com",
+      pathname: "/login/oauth/authorize",
+    });
+    expect(url.searchParams.get("client_id")).toEqual("0123");
+    expect(url.searchParams.get("state")).toMatch(/^\w+$/);
+    expect(url.searchParams.get("redirect_uri")).toEqual(
+      "http://localhost:12345"
+    );
+  });
 });