oidc-login 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,6 +82,19 @@ await createOidcLogin({ issuerUrl, clientId }).discover()
82
82
  The issuer URL must use HTTPS; plain HTTP works only on loopback hosts, such as `localhost`, for local development.
83
83
  Validate a URL that users enter with `isSecureIssuerUrl`.
84
84
 
85
+ Call `checkClient` with your redirect URI to check the client ID and secret as well, without signing anyone in:
86
+
87
+ ```ts
88
+ await createOidcLogin({ issuerUrl, clientId, clientSecret }).checkClient({ redirectUri })
89
+ ```
90
+
91
+ It exchanges a made-up code and expects the provider to reject only the code.
92
+ When the provider rejects the client, it rejects with `OidcLoginError` with the `client_rejected` code; when the response of the provider is inconclusive, with `client_check_failed`.
93
+ The OAuth error of the provider, if any, is in `error`.
94
+
95
+ The provider sees each check as a failed code exchange: it may log it as an error and count it against its rate limits.
96
+ Call `checkClient` when an administrator checks the settings, not as a periodic health check.
97
+
85
98
  ## Options
86
99
 
87
100
  - `scope`: scopes to request besides `openid`, `email`, and `profile`.
package/dist/index.d.mts CHANGED
@@ -1,18 +1,20 @@
1
1
  /**
2
- * Stage of the sign-in that failed.
2
+ * Stage of a login or of a check of the provider settings that failed.
3
3
  *
4
4
  * - `discovery_failed`: the issuer metadata could not be fetched or is invalid.
5
5
  * - `authorization_failed`: the provider redirected back with an error, such as `access_denied`.
6
6
  * - `callback_failed`: the callback does not match the flow, or the code exchange or ID token validation failed.
7
7
  * - `userinfo_failed`: the UserInfo request failed.
8
+ * - `client_rejected`: `checkClient` found that the provider rejects the client: its ID or secret is wrong, or it may not use the authorization code flow.
9
+ * - `client_check_failed`: `checkClient` could not tell whether the provider accepts the client.
8
10
  */
9
- type OidcLoginErrorCode = "discovery_failed" | "authorization_failed" | "callback_failed" | "userinfo_failed";
10
- /** Failure of an OpenID Connect sign-in; the original error is in `cause`. */
11
+ type OidcLoginErrorCode = "discovery_failed" | "authorization_failed" | "callback_failed" | "userinfo_failed" | "client_rejected" | "client_check_failed";
12
+ /** Failure of an OpenID Connect login or of a check of the provider settings; the original error is in `cause`. */
11
13
  export declare class OidcLoginError extends Error {
12
14
  name: string;
13
- /** Stage of the sign-in that failed. */
15
+ /** Stage that failed. */
14
16
  readonly code: OidcLoginErrorCode;
15
- /** OAuth error code that the provider returned with `authorization_failed`, such as `access_denied`. */
17
+ /** OAuth error code that the provider returned, such as `access_denied` with `authorization_failed` or `invalid_client` with `client_rejected`. */
16
18
  readonly error?: string;
17
19
  constructor(code: OidcLoginErrorCode, options?: {
18
20
  cause?: unknown;
@@ -101,6 +103,14 @@ interface OidcLogin {
101
103
  finish: (flow: OidcLoginFlow, callback: URL | URLSearchParams | string) => Promise<OidcUser>;
102
104
  /** Fetches the issuer metadata to check the issuer URL. */
103
105
  discover: () => Promise<void>;
106
+ /**
107
+ * Checks that the provider accepts the client ID and secret, without a user.
108
+ *
109
+ * It exchanges a made-up authorization code for `redirectUri` and resolves when the provider rejects only the code.
110
+ */
111
+ checkClient: (options: {
112
+ redirectUri: string | URL;
113
+ }) => Promise<void>;
104
114
  }
105
115
  /**
106
116
  * Creates an OpenID Connect login handler for one provider: the authorization code flow with PKCE, state, and nonce.
package/dist/index.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import * as client from "openid-client";
2
2
  // src/errors.ts
3
- /** Failure of an OpenID Connect sign-in; the original error is in `cause`. */
3
+ /** Failure of an OpenID Connect login or of a check of the provider settings; the original error is in `cause`. */
4
4
  var OidcLoginError = class extends Error {
5
5
  name = "OidcLoginError";
6
- /** Stage of the sign-in that failed. */
6
+ /** Stage that failed. */
7
7
  code;
8
- /** OAuth error code that the provider returned with `authorization_failed`, such as `access_denied`. */
8
+ /** OAuth error code that the provider returned, such as `access_denied` with `authorization_failed` or `invalid_client` with `client_rejected`. */
9
9
  error;
10
10
  constructor(code, options = {}) {
11
- super(options.message ?? `OpenID Connect sign-in failed: ${code}.`, { cause: options.cause });
11
+ super(options.message ?? `OpenID Connect request failed: ${code}.`, { cause: options.cause });
12
12
  this.code = code;
13
13
  this.error = options.error;
14
14
  }
@@ -93,8 +93,7 @@ function createOidcLogin({ issuerUrl, clientId, clientSecret, clientAuthenticati
93
93
  };
94
94
  return {
95
95
  async start({ redirectUri, loginHint }) {
96
- const redirectUrl = new URL(redirectUri);
97
- if (/[?#]/.test(redirectUrl.href)) throw new TypeError("The redirect URI must have no query or fragment.");
96
+ const redirectUrl = parseRedirectUri(redirectUri);
98
97
  const config = await getConfiguration();
99
98
  const codeVerifier = client.randomPKCECodeVerifier();
100
99
  const flow = {
@@ -140,23 +139,61 @@ function createOidcLogin({ issuerUrl, clientId, clientSecret, clientAuthenticati
140
139
  },
141
140
  async discover() {
142
141
  await getConfiguration();
142
+ },
143
+ async checkClient({ redirectUri }) {
144
+ const redirectUrl = parseRedirectUri(redirectUri);
145
+ const config = await getConfiguration();
146
+ const failure = await client.genericGrantRequest(config, "authorization_code", {
147
+ code: client.randomState(),
148
+ redirect_uri: redirectUrl.href,
149
+ code_verifier: client.randomPKCECodeVerifier()
150
+ }).then(() => void 0, (cause) => cause);
151
+ const error = await readOAuthError(failure);
152
+ if (error === "invalid_grant") return;
153
+ throw new OidcLoginError(error === "invalid_client" || error === "unauthorized_client" || error === void 0 && failure instanceof client.WWWAuthenticateChallengeError && failure.status === 401 ? "client_rejected" : "client_check_failed", {
154
+ cause: failure,
155
+ error
156
+ });
143
157
  }
144
158
  };
145
159
  }
160
+ /**
161
+ * Parses the redirect URI of a sign-in.
162
+ *
163
+ * It must have no query or fragment: openid-client sends the redirect URI of the token request without its query, which would not match the authorization request.
164
+ */
165
+ function parseRedirectUri(redirectUri) {
166
+ const redirectUrl = new URL(redirectUri);
167
+ if (/[?#]/.test(redirectUrl.href)) throw new TypeError("The redirect URI must have no query or fragment.");
168
+ return redirectUrl;
169
+ }
170
+ /** Reads the OAuth error code of a failed token request from its JSON body or its `WWW-Authenticate` challenge. */
171
+ async function readOAuthError(failure) {
172
+ if (failure instanceof client.ResponseBodyError) return failure.error;
173
+ if (failure instanceof client.WWWAuthenticateChallengeError) {
174
+ const body = await failure.response.clone().json().catch(() => void 0);
175
+ if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error;
176
+ return failure.cause.find((challenge) => challenge.parameters.error)?.parameters.error;
177
+ }
178
+ }
146
179
  async function discover({ issuer, clientId, clientSecret, clientAuthentication, fetch }) {
147
180
  const insecure = issuer.protocol === "http:" && isLoopbackHostname(issuer.hostname);
148
181
  const config = await client.discovery(issuer, clientId, clientSecret, clientSecret === void 0 ? client.None() : clientAuthentication === "client_secret_basic" ? client.ClientSecretBasic(clientSecret) : client.ClientSecretPost(clientSecret), {
149
182
  ...fetch ? { [client.customFetch]: fetch } : {},
150
183
  ...insecure ? { execute: [client.allowInsecureRequests] } : {}
151
184
  });
152
- const { authorization_endpoint, token_endpoint } = config.serverMetadata();
153
- if (![authorization_endpoint, token_endpoint].every((endpoint) => isEndpointUrl(endpoint, insecure))) throw new Error("The issuer metadata has no valid authorization or token endpoint.");
185
+ const { authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri } = config.serverMetadata();
186
+ if (![authorization_endpoint, token_endpoint].every((endpoint) => isEndpointUrl(endpoint, insecure)) || ![userinfo_endpoint, jwks_uri].every((endpoint) => endpoint === void 0 || isEndpointUrl(endpoint, insecure))) throw new Error("The issuer metadata has an invalid endpoint.");
154
187
  return config;
155
188
  }
156
- /** Checks that an endpoint from the issuer metadata is an absolute URL with an allowed protocol. */
189
+ /**
190
+ * Checks that an endpoint from the issuer metadata is an absolute URL with an allowed protocol.
191
+ *
192
+ * A plain HTTP issuer on a loopback host may name HTTPS endpoints or plain HTTP endpoints on loopback hosts, so that the client secret and tokens do not leave the machine unencrypted.
193
+ */
157
194
  function isEndpointUrl(value, insecure) {
158
195
  if (typeof value !== "string" || !URL.canParse(value)) return false;
159
- const { protocol } = new URL(value);
160
- return protocol === "https:" || insecure && protocol === "http:";
196
+ const { protocol, hostname } = new URL(value);
197
+ return protocol === "https:" || insecure && protocol === "http:" && isLoopbackHostname(hostname);
161
198
  }
162
199
  export { OidcLoginError, createOidcLogin, isSecureIssuerUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oidc-login",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "OpenID Connect login flow for web apps on top of openid-client",
5
5
  "homepage": "https://github.com/IlyaSemenov/oidc-login#readme",
6
6
  "bugs": "https://github.com/IlyaSemenov/oidc-login/issues",