@udibo/oauth2 0.9.1 → 0.9.2

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.
@@ -126,6 +126,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
126
126
  services: AuthorizationServerServices<Client, User, S>;
127
127
  /** The issuer identifier URL for this request. */
128
128
  issuer?: string;
129
+ /**
130
+ * Scopes advertised for this request in discovery metadata. Overrides the
131
+ * server-wide default, including when set to an empty array.
132
+ */
133
+ scopesSupported?: string[];
129
134
  /** URL of the authorization endpoint. Defaults to `${issuer}/authorize`. */
130
135
  authorizationEndpoint?: string;
131
136
  /** URL of the token endpoint. Defaults to `${issuer}/token`. */
@@ -164,7 +169,10 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
164
169
  resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
165
170
  /** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
166
171
  grants: AuthorizationServerGrants<Client, User, S>;
167
- /** Scopes supported by this server (advertised in RFC 8414 metadata). */
172
+ /**
173
+ * Default scopes advertised in RFC 8414 metadata when the request context
174
+ * does not supply scopesSupported.
175
+ */
168
176
  scopesSupported?: string[];
169
177
  /**
170
178
  * OIDC issuance keys. Configuring this turns the OIDC provider surface on:
@@ -1099,8 +1099,9 @@ export class AuthorizationServer extends ResourceServer {
1099
1099
  metadata.code_challenge_methods_supported = challengeMethods;
1100
1100
  }
1101
1101
  metadata.response_types_supported = hasAuthCodeGrant ? ["code"] : [];
1102
- if (this.scopesSupported) {
1103
- metadata.scopes_supported = this.scopesSupported;
1102
+ const scopesSupported = context.scopesSupported ?? this.scopesSupported;
1103
+ if (scopesSupported) {
1104
+ metadata.scopes_supported = scopesSupported;
1104
1105
  }
1105
1106
  if (this.signingKeys) {
1106
1107
  this.#advertiseEndpoints(metadata, endpoints, true);
@@ -50,10 +50,13 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
50
50
  * authentication, never a replacement for it, so a confidential client's
51
51
  * secret is still required on this grant.
52
52
  *
53
- * Defaults to `true`: confidential clients authenticate alongside PKCE.
54
- * Public clients, which have no secret, are unaffected. Setting `false`
55
- * explicitly enables legacy behavior where PKCE replaces client credentials;
56
- * use that opt-out only for an integration that requires it.
53
+ * Defaults to `true`: confidential clients authenticate alongside PKCE, and
54
+ * a public client authenticates by `client_id` alone — one that attaches a
55
+ * `client_secret` it was never issued is refused, as on every other grant.
56
+ * Setting `false` explicitly enables legacy behavior where PKCE replaces
57
+ * client credentials: a request carrying a `code_verifier` is looked up by
58
+ * id, so a presented secret is not checked at all. Use that opt-out only for
59
+ * an integration that requires it.
57
60
  *
58
61
  * @default true
59
62
  * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-4.1.3
@@ -17,13 +17,17 @@ export interface ClientServiceInterface<Client extends ClientInterface, User> {
17
17
  * always advertises `none` in `token_endpoint_auth_methods_supported`, so
18
18
  * an implementation that demands a secret from every client makes that
19
19
  * advertisement false.
20
+ * - A **public** client must resolve `undefined` when a non-empty `secret` is
21
+ * presented. It was issued no secret, so a request carrying one is
22
+ * misconfigured or confused about the client's type, and the server answers
23
+ * `invalid_client` rather than silently treating it as public. An empty
24
+ * `secret` carries no credential — a Basic header of `client_id:` decodes
25
+ * to one — and counts as presenting none, so the client resolves.
20
26
  * - A **confidential** client resolves only with its correct `secret`. With
21
27
  * the secret missing or wrong, resolve `undefined`.
22
28
  *
23
29
  * `runClientServiceContractTests` from `@udibo/oauth2/testing/contract` pins
24
- * both rules; run it against your implementation. It does not pin whether a
25
- * public client that presents a `secret` resolves, so do not rely on either
26
- * answer.
30
+ * all three rules; run it against your implementation.
27
31
  *
28
32
  * @param id The presented `client_id` — unauthenticated input.
29
33
  * @param secret The presented `client_secret`, omitted when the request
@@ -82,6 +82,17 @@ export function runClientServiceContractTests(options) {
82
82
  const auth = await clientService.getAuthenticated(client.id);
83
83
  assertEquals(auth?.id, client.id);
84
84
  });
85
+ it("returns undefined for a public client that presents a secret", async () => {
86
+ const client = makeClient(9);
87
+ await options.addClient(clientService, client);
88
+ assertStrictEquals(await clientService.getAuthenticated(client.id, "unissued"), undefined);
89
+ });
90
+ it("returns the public client when the presented secret is empty", async () => {
91
+ const client = makeClient(10);
92
+ await options.addClient(clientService, client);
93
+ const auth = await clientService.getAuthenticated(client.id, "");
94
+ assertEquals(auth?.id, client.id);
95
+ });
85
96
  it("returns undefined for a confidential client when no secret is supplied", async () => {
86
97
  const client = makeClient(3);
87
98
  await options.addClient(clientService, client, "secret");
@@ -99,7 +99,7 @@ export declare class MemoryUserService<U extends MemoryUserShape> implements Use
99
99
  * A client is treated as confidential when {@link add} is called with a
100
100
  * secret, and public when it is not — there is no separate flag to
101
101
  * remember. `getAuthenticated` requires the secret for confidential
102
- * clients and accepts a missing secret for public clients.
102
+ * clients, and requires public clients to present none.
103
103
  */
104
104
  export declare class MemoryClientService<C extends ClientInterface, U> implements ClientServiceInterface<C, U> {
105
105
  #private;
@@ -122,7 +122,9 @@ export declare class MemoryClientService<C extends ClientInterface, U> implement
122
122
  /**
123
123
  * Resolves the client when authentication succeeds, otherwise `undefined`.
124
124
  * Confidential clients (registered with a secret) require a matching
125
- * `secret`; public clients (registered without one) ignore it.
125
+ * `secret`; public clients (registered without one) must present none, so a
126
+ * secret they were never issued fails authentication rather than being
127
+ * ignored. An empty `secret` counts as none.
126
128
  */
127
129
  getAuthenticated(id: string, secret?: string): Promise<C | undefined>;
128
130
  /**
@@ -109,7 +109,7 @@ export class MemoryUserService {
109
109
  * A client is treated as confidential when {@link add} is called with a
110
110
  * secret, and public when it is not — there is no separate flag to
111
111
  * remember. `getAuthenticated` requires the secret for confidential
112
- * clients and accepts a missing secret for public clients.
112
+ * clients, and requires public clients to present none.
113
113
  */
114
114
  export class MemoryClientService {
115
115
  #clientsById = new Map();
@@ -150,19 +150,21 @@ export class MemoryClientService {
150
150
  /**
151
151
  * Resolves the client when authentication succeeds, otherwise `undefined`.
152
152
  * Confidential clients (registered with a secret) require a matching
153
- * `secret`; public clients (registered without one) ignore it.
153
+ * `secret`; public clients (registered without one) must present none, so a
154
+ * secret they were never issued fails authentication rather than being
155
+ * ignored. An empty `secret` counts as none.
154
156
  */
155
157
  async getAuthenticated(id, secret) {
156
158
  const client = this.#clientsById.get(id);
157
159
  if (!client)
158
160
  return undefined;
159
161
  const storedHash = this.#secretHashByClientId.get(id);
160
- if (storedHash) {
161
- if (!secret)
162
- return undefined;
163
- if (await sha256Hash(secret) !== storedHash)
164
- return undefined;
165
- }
162
+ if (!storedHash)
163
+ return secret ? undefined : client;
164
+ if (!secret)
165
+ return undefined;
166
+ if (await sha256Hash(secret) !== storedHash)
167
+ return undefined;
166
168
  return client;
167
169
  }
168
170
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "repository": {
6
6
  "type": "git",