@ttoss/http-server-auth 0.3.1 → 0.3.3
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 +7 -2
- package/dist/index.cjs +159 -15
- package/dist/index.d.cts +71 -6
- package/dist/index.d.mts +71 -6
- package/dist/index.mjs +147 -17
- package/package.json +5 -5
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
|
-
|
|
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
|
-
|
|
79
|
-
if (
|
|
80
|
-
if (
|
|
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
|
-
*
|
|
90
|
-
* Sets `ctx.state.user` and
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
*
|
|
57
|
-
* Sets `ctx.state.user` and
|
|
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 {
|
|
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
|
-
*
|
|
57
|
-
* Sets `ctx.state.user` and
|
|
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
|
-
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
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
|
-
*
|
|
59
|
-
* Sets `ctx.state.user` and
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.3",
|
|
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.
|
|
35
|
+
"@ttoss/auth-core": "^0.7.0"
|
|
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/
|
|
43
|
-
"@ttoss/
|
|
42
|
+
"@ttoss/http-server": "^0.7.1",
|
|
43
|
+
"@ttoss/config": "^1.37.17"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@ttoss/http-server": "^0.7.
|
|
46
|
+
"@ttoss/http-server": "^0.7.1"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public",
|