@ttoss/http-server-auth 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -101,7 +101,7 @@ On successful authentication the middleware sets:
101
101
 
102
102
  ```ts
103
103
  ctx.state.user; // AuthenticatedUser
104
- ctx.state.authStrategy; // 'jwt' | 'apiToken' | 'system'
104
+ ctx.state.authStrategy; // 'jwt' | 'apiToken' | 'system' | 'oauth'
105
105
  ```
106
106
 
107
107
  ```ts
@@ -120,6 +120,11 @@ type AuthenticatedUser = {
120
120
  - `jwt` — verifies HS256 JWT via `@ttoss/auth-core verifyJwt`; maps `sub`/`email` to user (override with `jwt.mapPayload(payload, ctx)`).
121
121
  - `apiToken` — hashes the token (SHA-256) and calls `apiToken.lookup(hash, ctx)`.
122
122
  - `system` — constant-time comparison against `system.secret`.
123
- 4. All strategies fail and `required` 401. Failure reason is never leaked.
123
+ - `oauth` — verifies an OAuth provider's Bearer token via `oauth.verify(token, ctx)` (wrap Cognito/Auth0/your own verifier); maps the payload to the user (claims like `scope` are preserved on `ctx.state.user`). A verified token missing an `oauth.requiredScopes` entry yields `403`.
124
+ 4. All strategies fail and `required` → 401. Failure reason is never leaked. Set `resourceMetadataUrl` to advertise the authorization server on `401` via `WWW-Authenticate` (RFC 9728).
124
125
 
125
126
  Both `apiToken.lookup` and `jwt.mapPayload` receive the Koa `ctx` as a second argument, enabling request-scoped work (e.g. reading a per-request connection off `ctx.db` or updating `lastUsedAt`). The single-argument signatures keep working — `ctx` is purely additive.
127
+
128
+ ## OAuth authorization server
129
+
130
+ The package also ships `oauthServer()` — a Koa `Router` that issues tokens (`/authorize`, `/token`, `/register`, discovery), a thin adapter over `createOAuthHandlers` from [`@ttoss/auth-core`](https://ttoss.dev/docs/modules/packages/auth-core). Pair it with the `oauth` verification strategy above when one deployment both issues and verifies. See the [OAuth Authorization Server](https://ttoss.dev/docs/engineering/guidelines/oauth-authorization-server) guideline.
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
32
32
  let node_crypto = require("node:crypto");
33
33
  node_crypto = __toESM(node_crypto, 1);
34
34
  let _ttoss_auth_core = require("@ttoss/auth-core");
35
+ let _ttoss_http_server = require("@ttoss/http-server");
35
36
 
36
37
  //#region src/origin.ts
37
38
  /**
@@ -73,38 +74,84 @@ var trySystem = (token, opts) => {
73
74
  if (a.length !== b.length || !node_crypto.default.timingSafeEqual(a, b)) return null;
74
75
  return opts.user;
75
76
  };
77
+ /** Sentinel: token verified but missing a required scope → 403, not 401. */
78
+ var FORBIDDEN = Symbol("forbidden");
79
+ var hasRequiredScopes = (requiredScopes, payload) => {
80
+ const tokenScopes = (typeof payload.scope === "string" ? payload.scope : "").split(" ");
81
+ return requiredScopes.every(s => {
82
+ return tokenScopes.includes(s);
83
+ });
84
+ };
85
+ var tryOauth = async (token, opts, ctx) => {
86
+ let payload;
87
+ try {
88
+ payload = await opts.verify(token, ctx);
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (!payload) return null;
93
+ if (opts.requiredScopes?.length && !hasRequiredScopes(opts.requiredScopes, payload)) return FORBIDDEN;
94
+ if (opts.mapPayload) return opts.mapPayload(payload, ctx);
95
+ return {
96
+ id: String(payload.sub ?? ""),
97
+ ...payload
98
+ };
99
+ };
100
+ var resolveStrategy = async (strategy, token, options, ctx) => {
101
+ if (strategy === "jwt" && options.jwt) return tryJwt(token, options.jwt, ctx);
102
+ if (strategy === "apiToken" && options.apiToken) return tryApiToken(token, options.apiToken, ctx);
103
+ if (strategy === "system" && options.system) return trySystem(token, options.system);
104
+ if (strategy === "oauth" && options.oauth) return tryOauth(token, options.oauth, ctx);
105
+ return null;
106
+ };
76
107
  var resolveUser = async (token, options, ctx) => {
77
108
  for (const strategy of options.strategies) {
78
- let user = null;
79
- if (strategy === "jwt" && options.jwt) user = tryJwt(token, options.jwt, ctx);else if (strategy === "apiToken" && options.apiToken) user = await tryApiToken(token, options.apiToken, ctx);else if (strategy === "system" && options.system) user = trySystem(token, options.system);
80
- if (user) return {
81
- user,
109
+ const result = await resolveStrategy(strategy, token, options, ctx);
110
+ if (result === FORBIDDEN) return FORBIDDEN;
111
+ if (result) return {
112
+ user: result,
82
113
  strategy
83
114
  };
84
115
  }
85
116
  return null;
86
117
  };
118
+ /** Throws 403 when an `allowedOrigins` list is set and the Origin doesn't match. */
119
+ var enforceOrigin = (ctx, allowedOrigins) => {
120
+ if (!allowedOrigins) return;
121
+ const origin = ctx.get("Origin");
122
+ if (origin && !isOriginAllowed(origin, allowedOrigins)) ctx.throw(403, "Invalid origin");
123
+ };
124
+ /** Extracts the Bearer token from the Authorization header, or `null`. */
125
+ var extractBearer = ctx => {
126
+ const authHeader = ctx.get("Authorization");
127
+ return authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
128
+ };
87
129
  /**
88
- * Koa middleware that authenticates requests via Bearer token.
89
- * Supports JWT, hashed API tokens, and a shared system secret.
90
- * Sets `ctx.state.user` and `ctx.state.authStrategy` on success.
130
+ * Koa middleware that authenticates requests via Bearer token. Supports JWT,
131
+ * hashed API tokens, a shared system secret, and OAuth provider tokens (the
132
+ * `oauth` strategy — the resource-server role). Sets `ctx.state.user` and
133
+ * `ctx.state.authStrategy` on success; emits `401` (with a `WWW-Authenticate`
134
+ * header, RFC 9728 when `resourceMetadataUrl` is set) for missing/invalid
135
+ * tokens, and `403` when a verified token is missing a required scope.
91
136
  */
92
137
  var authMiddleware = options => {
93
138
  const required = options.required ?? true;
94
- return async (ctx, next) => {
95
- if (options.allowedOrigins) {
96
- const origin = ctx.get("Origin");
97
- if (origin && !isOriginAllowed(origin, options.allowedOrigins)) ctx.throw(403, "Invalid origin");
139
+ const unauthorized = {
140
+ headers: {
141
+ "WWW-Authenticate": options.resourceMetadataUrl ? `Bearer resource_metadata="${options.resourceMetadataUrl}"` : "Bearer"
98
142
  }
99
- const authHeader = ctx.get("Authorization");
100
- const token = authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
143
+ };
144
+ return async (ctx, next) => {
145
+ enforceOrigin(ctx, options.allowedOrigins);
146
+ const token = extractBearer(ctx);
101
147
  if (!token) {
102
- if (required) ctx.throw(401, "Unauthorized");
148
+ if (required) ctx.throw(401, "Unauthorized", unauthorized);
103
149
  return next();
104
150
  }
105
151
  const result = await resolveUser(token, options, ctx);
152
+ if (result === FORBIDDEN) ctx.throw(403, "Forbidden");
106
153
  if (!result) {
107
- if (required) ctx.throw(401, "Unauthorized");
154
+ if (required) ctx.throw(401, "Unauthorized", unauthorized);
108
155
  return next();
109
156
  }
110
157
  ctx.state.user = result.user;
@@ -113,6 +160,89 @@ var authMiddleware = options => {
113
160
  };
114
161
  };
115
162
 
163
+ //#endregion
164
+ //#region src/oauthServer.ts
165
+ /** Normalizes a Koa query/header value (which may be an array) to a string. */
166
+ var firstValue = value => {
167
+ return Array.isArray(value) ? value[0] : value;
168
+ };
169
+ var toOAuthRequest = ctx => {
170
+ const query = {};
171
+ for (const [key, value] of Object.entries(ctx.query)) query[key] = firstValue(value);
172
+ const headers = {};
173
+ for (const [key, value] of Object.entries(ctx.headers)) headers[key] = firstValue(value);
174
+ return {
175
+ query,
176
+ body: ctx.request.body ?? {},
177
+ headers
178
+ };
179
+ };
180
+ var applyResponse = (ctx, res) => {
181
+ if (res.redirect !== void 0) {
182
+ ctx.redirect(res.redirect);
183
+ return;
184
+ }
185
+ ctx.status = res.status;
186
+ ctx.body = res.body;
187
+ };
188
+ /**
189
+ * Mounts an OAuth 2.1 Authorization Server (issuing tokens) as a Koa `Router`,
190
+ * adapting the runner-agnostic engine from `@ttoss/auth-core`. Exposes the
191
+ * authorization endpoint (PKCE S256), token endpoint (`authorization_code` +
192
+ * `refresh_token`), Dynamic Client Registration, and discovery metadata.
193
+ *
194
+ * This is the issuing side of OAuth; pair it with `authMiddleware`'s `oauth`
195
+ * strategy (the verifying side) when one deployment both issues and verifies.
196
+ *
197
+ * @example
198
+ * ```typescript
199
+ * import { App, bodyParser } from '@ttoss/http-server';
200
+ * import { oauthServer } from '@ttoss/http-server-auth';
201
+ *
202
+ * const app = new App();
203
+ * app.use(bodyParser());
204
+ * app.use(oauthServer({ issuer, clientStore, authCodeStore, issueTokens, onAuthorize }).routes());
205
+ * ```
206
+ */
207
+ var oauthServer = options => {
208
+ const engine = (0, _ttoss_auth_core.createOAuthHandlers)(options);
209
+ const router = new _ttoss_http_server.Router();
210
+ router.get("/.well-known/oauth-authorization-server", ctx => {
211
+ applyResponse(ctx, engine.authorizationServerMetadata());
212
+ });
213
+ const prm = engine.protectedResourceMetadata();
214
+ if (prm) router.get("/.well-known/oauth-protected-resource", ctx => {
215
+ applyResponse(ctx, prm);
216
+ });
217
+ router.get(engine.paths.authorize, async ctx => {
218
+ applyResponse(ctx, await engine.authorize(toOAuthRequest(ctx)));
219
+ });
220
+ router.post(engine.paths.token, async ctx => {
221
+ applyResponse(ctx, await engine.token(toOAuthRequest(ctx)));
222
+ });
223
+ router.post(engine.paths.register, async ctx => {
224
+ applyResponse(ctx, await engine.register(toOAuthRequest(ctx)));
225
+ });
226
+ return router;
227
+ };
228
+ /**
229
+ * Koa middleware that serves `GET /.well-known/oauth-protected-resource`
230
+ * (RFC 9728). Mount it **before** `authMiddleware` so the discovery endpoint
231
+ * stays unauthenticated (clients fetch it before they have a token).
232
+ */
233
+ var createProtectedResourceMetadataMiddleware = args => {
234
+ return async (ctx, next) => {
235
+ if (ctx.method === "GET" && ctx.path === "/.well-known/oauth-protected-resource") {
236
+ ctx.body = {
237
+ resource: args.resource,
238
+ authorization_servers: args.authorizationServers
239
+ };
240
+ return;
241
+ }
242
+ await next();
243
+ };
244
+ };
245
+
116
246
  //#endregion
117
247
  //#region src/requireAuth.ts
118
248
  /**
@@ -131,5 +261,19 @@ var requireAuth = options => {
131
261
 
132
262
  //#endregion
133
263
  exports.authMiddleware = authMiddleware;
264
+ Object.defineProperty(exports, 'createOAuthHandlers', {
265
+ enumerable: true,
266
+ get: function () {
267
+ return _ttoss_auth_core.createOAuthHandlers;
268
+ }
269
+ });
270
+ exports.createProtectedResourceMetadataMiddleware = createProtectedResourceMetadataMiddleware;
271
+ Object.defineProperty(exports, 'getWwwAuthenticateHeader', {
272
+ enumerable: true,
273
+ get: function () {
274
+ return _ttoss_auth_core.getWwwAuthenticateHeader;
275
+ }
276
+ });
134
277
  exports.isOriginAllowed = isOriginAllowed;
278
+ exports.oauthServer = oauthServer;
135
279
  exports.requireAuth = requireAuth;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
- import { Context, Next } from "@ttoss/http-server";
2
+ import { Context, Middleware, Next, Router } from "@ttoss/http-server";
3
+ import { AuthCodeStore, AuthorizeRequest, ClientStore, IssueTokensArgs, IssuedTokens, OAuthClient, OAuthClientMetadata, OAuthHandlers, OAuthServerOptions, OAuthServerOptions as OAuthServerOptions$1, OnAuthorizeArgs, OnAuthorizeResult, OnRefreshTokenArgs, OnRefreshTokenResult, StoredAuthorizationCode, createOAuthHandlers, getWwwAuthenticateHeader } from "@ttoss/auth-core";
3
4
 
4
5
  //#region src/types.d.ts
5
6
  type AuthenticatedUser = {
@@ -7,7 +8,7 @@ type AuthenticatedUser = {
7
8
  email?: string;
8
9
  [key: string]: unknown;
9
10
  };
10
- type AuthStrategy = 'jwt' | 'apiToken' | 'system';
11
+ type AuthStrategy = 'jwt' | 'apiToken' | 'system' | 'oauth';
11
12
  type JwtOptions = {
12
13
  secret: string;
13
14
  /**
@@ -33,11 +34,41 @@ type SystemOptions = {
33
34
  secret: string; /** User attached to ctx.state.user for system calls. */
34
35
  user: AuthenticatedUser;
35
36
  };
37
+ type OAuthOptions = {
38
+ /**
39
+ * Verifies a Bearer token issued by an OAuth provider (Cognito, Auth0,
40
+ * Keycloak, your own authorization server, …). Resolve with the verified
41
+ * payload, return `null`, or throw to reject — both rejection forms yield a
42
+ * `401`. Wrap a provider SDK here (e.g. `CognitoJwtVerifier` from
43
+ * `@ttoss/auth-core/amazon-cognito`) to keep this package provider-agnostic.
44
+ *
45
+ * Receives the Koa `ctx` as a second argument for request-scoped work.
46
+ */
47
+ verify: (token: string, ctx: Context) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null;
48
+ /**
49
+ * Maps the verified payload to an AuthenticatedUser. Defaults to the payload
50
+ * itself with `id` taken from `sub`, so claims like `scope` remain available
51
+ * on `ctx.state.user`.
52
+ */
53
+ mapPayload?: (payload: Record<string, unknown>, ctx: Context) => AuthenticatedUser | null;
54
+ /**
55
+ * Scopes that must all be present on the token's space-separated `scope`
56
+ * claim. A verified token missing any of them yields a `403`.
57
+ */
58
+ requiredScopes?: string[];
59
+ };
36
60
  type AuthMiddlewareOptions = {
37
61
  /** Ordered list of strategies to attempt. First match wins. */strategies: AuthStrategy[];
38
62
  jwt?: JwtOptions;
39
63
  apiToken?: ApiTokenOptions;
40
64
  system?: SystemOptions;
65
+ oauth?: OAuthOptions;
66
+ /**
67
+ * When set, a `401` response carries
68
+ * `WWW-Authenticate: Bearer resource_metadata="<url>"` (RFC 9728) so OAuth
69
+ * clients can discover the authorization server. Otherwise a bare `Bearer`.
70
+ */
71
+ resourceMetadataUrl?: string;
41
72
  /**
42
73
  * Optional origin allowlist. Strings are exact-matched; RegExps are tested.
43
74
  * Requests without an Origin header are never rejected by this check.
@@ -52,12 +83,46 @@ type AuthMiddlewareOptions = {
52
83
  //#endregion
53
84
  //#region src/authMiddleware.d.ts
54
85
  /**
55
- * Koa middleware that authenticates requests via Bearer token.
56
- * Supports JWT, hashed API tokens, and a shared system secret.
57
- * Sets `ctx.state.user` and `ctx.state.authStrategy` on success.
86
+ * Koa middleware that authenticates requests via Bearer token. Supports JWT,
87
+ * hashed API tokens, a shared system secret, and OAuth provider tokens (the
88
+ * `oauth` strategy — the resource-server role). Sets `ctx.state.user` and
89
+ * `ctx.state.authStrategy` on success; emits `401` (with a `WWW-Authenticate`
90
+ * header, RFC 9728 when `resourceMetadataUrl` is set) for missing/invalid
91
+ * tokens, and `403` when a verified token is missing a required scope.
58
92
  */
59
93
  declare const authMiddleware: (options: AuthMiddlewareOptions) => (ctx: Context, next: Next) => Promise<void>;
60
94
  //#endregion
95
+ //#region src/oauthServer.d.ts
96
+ /**
97
+ * Mounts an OAuth 2.1 Authorization Server (issuing tokens) as a Koa `Router`,
98
+ * adapting the runner-agnostic engine from `@ttoss/auth-core`. Exposes the
99
+ * authorization endpoint (PKCE S256), token endpoint (`authorization_code` +
100
+ * `refresh_token`), Dynamic Client Registration, and discovery metadata.
101
+ *
102
+ * This is the issuing side of OAuth; pair it with `authMiddleware`'s `oauth`
103
+ * strategy (the verifying side) when one deployment both issues and verifies.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * import { App, bodyParser } from '@ttoss/http-server';
108
+ * import { oauthServer } from '@ttoss/http-server-auth';
109
+ *
110
+ * const app = new App();
111
+ * app.use(bodyParser());
112
+ * app.use(oauthServer({ issuer, clientStore, authCodeStore, issueTokens, onAuthorize }).routes());
113
+ * ```
114
+ */
115
+ declare const oauthServer: (options: OAuthServerOptions$1) => Router;
116
+ /**
117
+ * Koa middleware that serves `GET /.well-known/oauth-protected-resource`
118
+ * (RFC 9728). Mount it **before** `authMiddleware` so the discovery endpoint
119
+ * stays unauthenticated (clients fetch it before they have a token).
120
+ */
121
+ declare const createProtectedResourceMetadataMiddleware: (args: {
122
+ /** The protected resource's identifier URI. */resource: string; /** Authorization server issuer URIs that issue tokens for this resource. */
123
+ authorizationServers: string[];
124
+ }) => Middleware;
125
+ //#endregion
61
126
  //#region src/origin.d.ts
62
127
  /**
63
128
  * Returns true if origin matches any entry in the allowlist.
@@ -75,4 +140,4 @@ declare const isOriginAllowed: (origin: string, allowedOrigins: Array<string | R
75
140
  */
76
141
  declare const requireAuth: (options: AuthMiddlewareOptions) => ((ctx: Context, next: Next) => Promise<void>);
77
142
  //#endregion
78
- export { type ApiTokenOptions, type AuthMiddlewareOptions, type AuthStrategy, type AuthenticatedUser, type JwtOptions, type SystemOptions, authMiddleware, isOriginAllowed, requireAuth };
143
+ export { type ApiTokenOptions, type AuthCodeStore, type AuthMiddlewareOptions, type AuthStrategy, type AuthenticatedUser, type AuthorizeRequest, type ClientStore, type IssueTokensArgs, type IssuedTokens, type JwtOptions, type OAuthClient, type OAuthClientMetadata, type OAuthHandlers, type OAuthOptions, type OAuthServerOptions, type OnAuthorizeArgs, type OnAuthorizeResult, type OnRefreshTokenArgs, type OnRefreshTokenResult, type StoredAuthorizationCode, type SystemOptions, authMiddleware, createOAuthHandlers, createProtectedResourceMetadataMiddleware, getWwwAuthenticateHeader, isOriginAllowed, oauthServer, requireAuth };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
- import { Context, Next } from "@ttoss/http-server";
2
+ import { AuthCodeStore, AuthorizeRequest, ClientStore, IssueTokensArgs, IssuedTokens, OAuthClient, OAuthClientMetadata, OAuthHandlers, OAuthServerOptions, OAuthServerOptions as OAuthServerOptions$1, OnAuthorizeArgs, OnAuthorizeResult, OnRefreshTokenArgs, OnRefreshTokenResult, StoredAuthorizationCode, createOAuthHandlers, getWwwAuthenticateHeader } from "@ttoss/auth-core";
3
+ import { Context, Middleware, Next, Router } from "@ttoss/http-server";
3
4
 
4
5
  //#region src/types.d.ts
5
6
  type AuthenticatedUser = {
@@ -7,7 +8,7 @@ type AuthenticatedUser = {
7
8
  email?: string;
8
9
  [key: string]: unknown;
9
10
  };
10
- type AuthStrategy = 'jwt' | 'apiToken' | 'system';
11
+ type AuthStrategy = 'jwt' | 'apiToken' | 'system' | 'oauth';
11
12
  type JwtOptions = {
12
13
  secret: string;
13
14
  /**
@@ -33,11 +34,41 @@ type SystemOptions = {
33
34
  secret: string; /** User attached to ctx.state.user for system calls. */
34
35
  user: AuthenticatedUser;
35
36
  };
37
+ type OAuthOptions = {
38
+ /**
39
+ * Verifies a Bearer token issued by an OAuth provider (Cognito, Auth0,
40
+ * Keycloak, your own authorization server, …). Resolve with the verified
41
+ * payload, return `null`, or throw to reject — both rejection forms yield a
42
+ * `401`. Wrap a provider SDK here (e.g. `CognitoJwtVerifier` from
43
+ * `@ttoss/auth-core/amazon-cognito`) to keep this package provider-agnostic.
44
+ *
45
+ * Receives the Koa `ctx` as a second argument for request-scoped work.
46
+ */
47
+ verify: (token: string, ctx: Context) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null;
48
+ /**
49
+ * Maps the verified payload to an AuthenticatedUser. Defaults to the payload
50
+ * itself with `id` taken from `sub`, so claims like `scope` remain available
51
+ * on `ctx.state.user`.
52
+ */
53
+ mapPayload?: (payload: Record<string, unknown>, ctx: Context) => AuthenticatedUser | null;
54
+ /**
55
+ * Scopes that must all be present on the token's space-separated `scope`
56
+ * claim. A verified token missing any of them yields a `403`.
57
+ */
58
+ requiredScopes?: string[];
59
+ };
36
60
  type AuthMiddlewareOptions = {
37
61
  /** Ordered list of strategies to attempt. First match wins. */strategies: AuthStrategy[];
38
62
  jwt?: JwtOptions;
39
63
  apiToken?: ApiTokenOptions;
40
64
  system?: SystemOptions;
65
+ oauth?: OAuthOptions;
66
+ /**
67
+ * When set, a `401` response carries
68
+ * `WWW-Authenticate: Bearer resource_metadata="<url>"` (RFC 9728) so OAuth
69
+ * clients can discover the authorization server. Otherwise a bare `Bearer`.
70
+ */
71
+ resourceMetadataUrl?: string;
41
72
  /**
42
73
  * Optional origin allowlist. Strings are exact-matched; RegExps are tested.
43
74
  * Requests without an Origin header are never rejected by this check.
@@ -52,12 +83,46 @@ type AuthMiddlewareOptions = {
52
83
  //#endregion
53
84
  //#region src/authMiddleware.d.ts
54
85
  /**
55
- * Koa middleware that authenticates requests via Bearer token.
56
- * Supports JWT, hashed API tokens, and a shared system secret.
57
- * Sets `ctx.state.user` and `ctx.state.authStrategy` on success.
86
+ * Koa middleware that authenticates requests via Bearer token. Supports JWT,
87
+ * hashed API tokens, a shared system secret, and OAuth provider tokens (the
88
+ * `oauth` strategy — the resource-server role). Sets `ctx.state.user` and
89
+ * `ctx.state.authStrategy` on success; emits `401` (with a `WWW-Authenticate`
90
+ * header, RFC 9728 when `resourceMetadataUrl` is set) for missing/invalid
91
+ * tokens, and `403` when a verified token is missing a required scope.
58
92
  */
59
93
  declare const authMiddleware: (options: AuthMiddlewareOptions) => (ctx: Context, next: Next) => Promise<void>;
60
94
  //#endregion
95
+ //#region src/oauthServer.d.ts
96
+ /**
97
+ * Mounts an OAuth 2.1 Authorization Server (issuing tokens) as a Koa `Router`,
98
+ * adapting the runner-agnostic engine from `@ttoss/auth-core`. Exposes the
99
+ * authorization endpoint (PKCE S256), token endpoint (`authorization_code` +
100
+ * `refresh_token`), Dynamic Client Registration, and discovery metadata.
101
+ *
102
+ * This is the issuing side of OAuth; pair it with `authMiddleware`'s `oauth`
103
+ * strategy (the verifying side) when one deployment both issues and verifies.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * import { App, bodyParser } from '@ttoss/http-server';
108
+ * import { oauthServer } from '@ttoss/http-server-auth';
109
+ *
110
+ * const app = new App();
111
+ * app.use(bodyParser());
112
+ * app.use(oauthServer({ issuer, clientStore, authCodeStore, issueTokens, onAuthorize }).routes());
113
+ * ```
114
+ */
115
+ declare const oauthServer: (options: OAuthServerOptions$1) => Router;
116
+ /**
117
+ * Koa middleware that serves `GET /.well-known/oauth-protected-resource`
118
+ * (RFC 9728). Mount it **before** `authMiddleware` so the discovery endpoint
119
+ * stays unauthenticated (clients fetch it before they have a token).
120
+ */
121
+ declare const createProtectedResourceMetadataMiddleware: (args: {
122
+ /** The protected resource's identifier URI. */resource: string; /** Authorization server issuer URIs that issue tokens for this resource. */
123
+ authorizationServers: string[];
124
+ }) => Middleware;
125
+ //#endregion
61
126
  //#region src/origin.d.ts
62
127
  /**
63
128
  * Returns true if origin matches any entry in the allowlist.
@@ -75,4 +140,4 @@ declare const isOriginAllowed: (origin: string, allowedOrigins: Array<string | R
75
140
  */
76
141
  declare const requireAuth: (options: AuthMiddlewareOptions) => ((ctx: Context, next: Next) => Promise<void>);
77
142
  //#endregion
78
- export { type ApiTokenOptions, type AuthMiddlewareOptions, type AuthStrategy, type AuthenticatedUser, type JwtOptions, type SystemOptions, authMiddleware, isOriginAllowed, requireAuth };
143
+ export { type ApiTokenOptions, type AuthCodeStore, type AuthMiddlewareOptions, type AuthStrategy, type AuthenticatedUser, type AuthorizeRequest, type ClientStore, type IssueTokensArgs, type IssuedTokens, type JwtOptions, type OAuthClient, type OAuthClientMetadata, type OAuthHandlers, type OAuthOptions, type OAuthServerOptions, type OnAuthorizeArgs, type OnAuthorizeResult, type OnRefreshTokenArgs, type OnRefreshTokenResult, type StoredAuthorizationCode, type SystemOptions, authMiddleware, createOAuthHandlers, createProtectedResourceMetadataMiddleware, getWwwAuthenticateHeader, isOriginAllowed, oauthServer, requireAuth };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Powered by @ttoss/config. https://ttoss.dev/docs/modules/packages/config/ */
2
2
  import crypto from "node:crypto";
3
- import { hashApiToken, verifyJwt } from "@ttoss/auth-core";
3
+ import { createOAuthHandlers, createOAuthHandlers as createOAuthHandlers$1, getWwwAuthenticateHeader, hashApiToken, verifyJwt } from "@ttoss/auth-core";
4
+ import { Router } from "@ttoss/http-server";
4
5
 
5
6
  //#region src/origin.ts
6
7
  /**
@@ -42,38 +43,84 @@ var trySystem = (token, opts) => {
42
43
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
43
44
  return opts.user;
44
45
  };
46
+ /** Sentinel: token verified but missing a required scope → 403, not 401. */
47
+ var FORBIDDEN = Symbol("forbidden");
48
+ var hasRequiredScopes = (requiredScopes, payload) => {
49
+ const tokenScopes = (typeof payload.scope === "string" ? payload.scope : "").split(" ");
50
+ return requiredScopes.every(s => {
51
+ return tokenScopes.includes(s);
52
+ });
53
+ };
54
+ var tryOauth = async (token, opts, ctx) => {
55
+ let payload;
56
+ try {
57
+ payload = await opts.verify(token, ctx);
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (!payload) return null;
62
+ if (opts.requiredScopes?.length && !hasRequiredScopes(opts.requiredScopes, payload)) return FORBIDDEN;
63
+ if (opts.mapPayload) return opts.mapPayload(payload, ctx);
64
+ return {
65
+ id: String(payload.sub ?? ""),
66
+ ...payload
67
+ };
68
+ };
69
+ var resolveStrategy = async (strategy, token, options, ctx) => {
70
+ if (strategy === "jwt" && options.jwt) return tryJwt(token, options.jwt, ctx);
71
+ if (strategy === "apiToken" && options.apiToken) return tryApiToken(token, options.apiToken, ctx);
72
+ if (strategy === "system" && options.system) return trySystem(token, options.system);
73
+ if (strategy === "oauth" && options.oauth) return tryOauth(token, options.oauth, ctx);
74
+ return null;
75
+ };
45
76
  var resolveUser = async (token, options, ctx) => {
46
77
  for (const strategy of options.strategies) {
47
- let user = null;
48
- if (strategy === "jwt" && options.jwt) user = tryJwt(token, options.jwt, ctx);else if (strategy === "apiToken" && options.apiToken) user = await tryApiToken(token, options.apiToken, ctx);else if (strategy === "system" && options.system) user = trySystem(token, options.system);
49
- if (user) return {
50
- user,
78
+ const result = await resolveStrategy(strategy, token, options, ctx);
79
+ if (result === FORBIDDEN) return FORBIDDEN;
80
+ if (result) return {
81
+ user: result,
51
82
  strategy
52
83
  };
53
84
  }
54
85
  return null;
55
86
  };
87
+ /** Throws 403 when an `allowedOrigins` list is set and the Origin doesn't match. */
88
+ var enforceOrigin = (ctx, allowedOrigins) => {
89
+ if (!allowedOrigins) return;
90
+ const origin = ctx.get("Origin");
91
+ if (origin && !isOriginAllowed(origin, allowedOrigins)) ctx.throw(403, "Invalid origin");
92
+ };
93
+ /** Extracts the Bearer token from the Authorization header, or `null`. */
94
+ var extractBearer = ctx => {
95
+ const authHeader = ctx.get("Authorization");
96
+ return authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
97
+ };
56
98
  /**
57
- * Koa middleware that authenticates requests via Bearer token.
58
- * Supports JWT, hashed API tokens, and a shared system secret.
59
- * Sets `ctx.state.user` and `ctx.state.authStrategy` on success.
99
+ * Koa middleware that authenticates requests via Bearer token. Supports JWT,
100
+ * hashed API tokens, a shared system secret, and OAuth provider tokens (the
101
+ * `oauth` strategy — the resource-server role). Sets `ctx.state.user` and
102
+ * `ctx.state.authStrategy` on success; emits `401` (with a `WWW-Authenticate`
103
+ * header, RFC 9728 when `resourceMetadataUrl` is set) for missing/invalid
104
+ * tokens, and `403` when a verified token is missing a required scope.
60
105
  */
61
106
  var authMiddleware = options => {
62
107
  const required = options.required ?? true;
63
- return async (ctx, next) => {
64
- if (options.allowedOrigins) {
65
- const origin = ctx.get("Origin");
66
- if (origin && !isOriginAllowed(origin, options.allowedOrigins)) ctx.throw(403, "Invalid origin");
108
+ const unauthorized = {
109
+ headers: {
110
+ "WWW-Authenticate": options.resourceMetadataUrl ? `Bearer resource_metadata="${options.resourceMetadataUrl}"` : "Bearer"
67
111
  }
68
- const authHeader = ctx.get("Authorization");
69
- const token = authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
112
+ };
113
+ return async (ctx, next) => {
114
+ enforceOrigin(ctx, options.allowedOrigins);
115
+ const token = extractBearer(ctx);
70
116
  if (!token) {
71
- if (required) ctx.throw(401, "Unauthorized");
117
+ if (required) ctx.throw(401, "Unauthorized", unauthorized);
72
118
  return next();
73
119
  }
74
120
  const result = await resolveUser(token, options, ctx);
121
+ if (result === FORBIDDEN) ctx.throw(403, "Forbidden");
75
122
  if (!result) {
76
- if (required) ctx.throw(401, "Unauthorized");
123
+ if (required) ctx.throw(401, "Unauthorized", unauthorized);
77
124
  return next();
78
125
  }
79
126
  ctx.state.user = result.user;
@@ -82,6 +129,89 @@ var authMiddleware = options => {
82
129
  };
83
130
  };
84
131
 
132
+ //#endregion
133
+ //#region src/oauthServer.ts
134
+ /** Normalizes a Koa query/header value (which may be an array) to a string. */
135
+ var firstValue = value => {
136
+ return Array.isArray(value) ? value[0] : value;
137
+ };
138
+ var toOAuthRequest = ctx => {
139
+ const query = {};
140
+ for (const [key, value] of Object.entries(ctx.query)) query[key] = firstValue(value);
141
+ const headers = {};
142
+ for (const [key, value] of Object.entries(ctx.headers)) headers[key] = firstValue(value);
143
+ return {
144
+ query,
145
+ body: ctx.request.body ?? {},
146
+ headers
147
+ };
148
+ };
149
+ var applyResponse = (ctx, res) => {
150
+ if (res.redirect !== void 0) {
151
+ ctx.redirect(res.redirect);
152
+ return;
153
+ }
154
+ ctx.status = res.status;
155
+ ctx.body = res.body;
156
+ };
157
+ /**
158
+ * Mounts an OAuth 2.1 Authorization Server (issuing tokens) as a Koa `Router`,
159
+ * adapting the runner-agnostic engine from `@ttoss/auth-core`. Exposes the
160
+ * authorization endpoint (PKCE S256), token endpoint (`authorization_code` +
161
+ * `refresh_token`), Dynamic Client Registration, and discovery metadata.
162
+ *
163
+ * This is the issuing side of OAuth; pair it with `authMiddleware`'s `oauth`
164
+ * strategy (the verifying side) when one deployment both issues and verifies.
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * import { App, bodyParser } from '@ttoss/http-server';
169
+ * import { oauthServer } from '@ttoss/http-server-auth';
170
+ *
171
+ * const app = new App();
172
+ * app.use(bodyParser());
173
+ * app.use(oauthServer({ issuer, clientStore, authCodeStore, issueTokens, onAuthorize }).routes());
174
+ * ```
175
+ */
176
+ var oauthServer = options => {
177
+ const engine = createOAuthHandlers$1(options);
178
+ const router = new Router();
179
+ router.get("/.well-known/oauth-authorization-server", ctx => {
180
+ applyResponse(ctx, engine.authorizationServerMetadata());
181
+ });
182
+ const prm = engine.protectedResourceMetadata();
183
+ if (prm) router.get("/.well-known/oauth-protected-resource", ctx => {
184
+ applyResponse(ctx, prm);
185
+ });
186
+ router.get(engine.paths.authorize, async ctx => {
187
+ applyResponse(ctx, await engine.authorize(toOAuthRequest(ctx)));
188
+ });
189
+ router.post(engine.paths.token, async ctx => {
190
+ applyResponse(ctx, await engine.token(toOAuthRequest(ctx)));
191
+ });
192
+ router.post(engine.paths.register, async ctx => {
193
+ applyResponse(ctx, await engine.register(toOAuthRequest(ctx)));
194
+ });
195
+ return router;
196
+ };
197
+ /**
198
+ * Koa middleware that serves `GET /.well-known/oauth-protected-resource`
199
+ * (RFC 9728). Mount it **before** `authMiddleware` so the discovery endpoint
200
+ * stays unauthenticated (clients fetch it before they have a token).
201
+ */
202
+ var createProtectedResourceMetadataMiddleware = args => {
203
+ return async (ctx, next) => {
204
+ if (ctx.method === "GET" && ctx.path === "/.well-known/oauth-protected-resource") {
205
+ ctx.body = {
206
+ resource: args.resource,
207
+ authorization_servers: args.authorizationServers
208
+ };
209
+ return;
210
+ }
211
+ await next();
212
+ };
213
+ };
214
+
85
215
  //#endregion
86
216
  //#region src/requireAuth.ts
87
217
  /**
@@ -99,4 +229,4 @@ var requireAuth = options => {
99
229
  };
100
230
 
101
231
  //#endregion
102
- export { authMiddleware, isOriginAllowed, requireAuth };
232
+ export { authMiddleware, createOAuthHandlers, createProtectedResourceMetadataMiddleware, getWwwAuthenticateHeader, isOriginAllowed, oauthServer, requireAuth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttoss/http-server-auth",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Authentication middleware for @ttoss/http-server",
5
5
  "keywords": [
6
6
  "auth",
@@ -32,18 +32,18 @@
32
32
  "dist"
33
33
  ],
34
34
  "dependencies": {
35
- "@ttoss/auth-core": "^0.6.0"
35
+ "@ttoss/auth-core": "^0.6.2"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/koa": "^3.0.3",
39
39
  "jest": "^30.4.2",
40
40
  "supertest": "^7.2.2",
41
41
  "tsdown": "^0.22.2",
42
- "@ttoss/http-server": "^0.6.1",
43
- "@ttoss/config": "^1.37.17"
42
+ "@ttoss/config": "^1.37.17",
43
+ "@ttoss/http-server": "^0.7.1"
44
44
  },
45
45
  "peerDependencies": {
46
- "@ttoss/http-server": "^0.6.1"
46
+ "@ttoss/http-server": "^0.7.1"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public",