@c9up/warden 0.1.0
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/LICENSE +21 -0
- package/README.md +35 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +81 -0
- package/scripts/copy-napi.mjs +67 -0
- package/src/AuthManager.ts +228 -0
- package/src/AuthRateLimiter.ts +105 -0
- package/src/Guard.ts +68 -0
- package/src/RefreshTokenStore.ts +67 -0
- package/src/TokenBlacklist.ts +58 -0
- package/src/WardenProvider.ts +84 -0
- package/src/bouncer/AuthorizationResponse.ts +38 -0
- package/src/bouncer/BasePolicy.ts +66 -0
- package/src/bouncer/Bouncer.ts +236 -0
- package/src/bouncer/PolicyAuthorizer.ts +125 -0
- package/src/bouncer/decorators.ts +44 -0
- package/src/bouncer/evaluate.ts +104 -0
- package/src/bouncer/policyContext.ts +53 -0
- package/src/bouncer/types.ts +67 -0
- package/src/config.ts +48 -0
- package/src/configure.ts +59 -0
- package/src/errors.ts +25 -0
- package/src/firstcontact/FirstContactManager.ts +54 -0
- package/src/firstcontact/drivers/GitHubDriver.ts +78 -0
- package/src/firstcontact/drivers/GoogleDriver.ts +81 -0
- package/src/firstcontact/types.ts +38 -0
- package/src/index.ts +77 -0
- package/src/middleware.ts +283 -0
- package/src/native.ts +53 -0
- package/src/rights/MemoryRightsStore.ts +92 -0
- package/src/rights/RightsResolver.ts +84 -0
- package/src/rights/types.ts +58 -0
- package/src/services/main.ts +40 -0
- package/src/standalone.ts +128 -0
- package/src/strategies/ApiKeyStrategy.ts +54 -0
- package/src/strategies/JwtStrategy.ts +217 -0
- package/src/strategies/SessionStrategy.ts +105 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared evaluation pipeline — the single place that encodes the Adonis Bouncer
|
|
3
|
+
* evaluation order (D5), used by BOTH standalone abilities (no hooks) and policy
|
|
4
|
+
* methods (with `before`/`after`). Pinned by `bouncer-parity.test.ts` (AC-E2).
|
|
5
|
+
*
|
|
6
|
+
* Order: (1) `before` — a non-`undefined` return short-circuits the action;
|
|
7
|
+
* (2) guest-deny — `user === null` && !allowGuest ⇒ auto-deny WITHOUT running the
|
|
8
|
+
* action; (3) run the action; (4) `after` — a non-`undefined` return overrides.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { UserPayload } from "../AuthManager.js";
|
|
12
|
+
import { WardenError } from "../errors.js";
|
|
13
|
+
import { AuthorizationResponse } from "./AuthorizationResponse.js";
|
|
14
|
+
import type { AuthorizerResponse, HookResponse } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** A policy action method / ability callback, structurally. */
|
|
17
|
+
export type Action = (
|
|
18
|
+
user: UserPayload | null,
|
|
19
|
+
...args: unknown[]
|
|
20
|
+
) => AuthorizerResponse;
|
|
21
|
+
|
|
22
|
+
/** Boolean sugar → response; an explicit response passes through (D7). */
|
|
23
|
+
export function normalizeResponse(
|
|
24
|
+
value: boolean | AuthorizationResponse,
|
|
25
|
+
): AuthorizationResponse {
|
|
26
|
+
if (value instanceof AuthorizationResponse) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return value ? AuthorizationResponse.allow() : AuthorizationResponse.deny();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Type guard for a callable action. A function's parameter/return types are not
|
|
34
|
+
* observable at runtime, so this asserts the structural `Action` shape from a
|
|
35
|
+
* `typeof === "function"` check — a type guard (not an `as` cast), the standard
|
|
36
|
+
* escape for dynamic dispatch.
|
|
37
|
+
*/
|
|
38
|
+
export function isAction(value: unknown): value is Action {
|
|
39
|
+
return typeof value === "function";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Throw the canonical authorization failure for a denied response (D2). */
|
|
43
|
+
export function throwAuthorizationFailure(
|
|
44
|
+
response: AuthorizationResponse,
|
|
45
|
+
): never {
|
|
46
|
+
throw new WardenError(
|
|
47
|
+
"AUTHORIZATION_FAILURE",
|
|
48
|
+
response.message ?? "Authorization failed",
|
|
49
|
+
{
|
|
50
|
+
hint: "The current user is not authorized for this action.",
|
|
51
|
+
status: response.status ?? 403,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Run the D5 evaluation pipeline and resolve to the final response. */
|
|
57
|
+
export async function evaluate(params: {
|
|
58
|
+
user: UserPayload | null;
|
|
59
|
+
action: string;
|
|
60
|
+
allowGuest: boolean;
|
|
61
|
+
run: (user: UserPayload | null) => AuthorizerResponse;
|
|
62
|
+
args: unknown[];
|
|
63
|
+
before?: (
|
|
64
|
+
user: UserPayload | null,
|
|
65
|
+
action: string,
|
|
66
|
+
...args: unknown[]
|
|
67
|
+
) => HookResponse;
|
|
68
|
+
after?: (
|
|
69
|
+
user: UserPayload | null,
|
|
70
|
+
action: string,
|
|
71
|
+
result: AuthorizationResponse,
|
|
72
|
+
) => HookResponse;
|
|
73
|
+
}): Promise<AuthorizationResponse> {
|
|
74
|
+
const { user, action, allowGuest, run, args, before, after } = params;
|
|
75
|
+
|
|
76
|
+
let response: AuthorizationResponse | undefined;
|
|
77
|
+
|
|
78
|
+
// (1) before — non-undefined short-circuits the action.
|
|
79
|
+
if (before) {
|
|
80
|
+
const early = await before(user, action, ...args);
|
|
81
|
+
if (early !== undefined) {
|
|
82
|
+
response = normalizeResponse(early);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// (2) guest-deny + (3) action — only if before did not short-circuit.
|
|
87
|
+
if (response === undefined) {
|
|
88
|
+
if (user === null && !allowGuest) {
|
|
89
|
+
response = AuthorizationResponse.deny();
|
|
90
|
+
} else {
|
|
91
|
+
response = normalizeResponse(await run(user));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// (4) after — non-undefined overrides.
|
|
96
|
+
if (after) {
|
|
97
|
+
const override = await after(user, action, response);
|
|
98
|
+
if (override !== undefined) {
|
|
99
|
+
response = normalizeResponse(override);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return response;
|
|
104
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal policy context attach — Layer-2 ⨯ Layer-1 wiring (Epic 56, story 56.3).
|
|
3
|
+
*
|
|
4
|
+
* The active `scope` and the resolved `EffectivePermissions` for the in-flight
|
|
5
|
+
* `(user, scope)` are attached to a freshly-constructed policy via a package-
|
|
6
|
+
* internal `WeakMap` (D2) — NOT a public setter on `BasePolicy` and NOT barrelled.
|
|
7
|
+
* `BasePolicy`'s `this.scope` / `this.permissions` getters read it back; the
|
|
8
|
+
* `PolicyAuthorizer` writes it before dispatch. A policy used without a Bouncer
|
|
9
|
+
* has no entry, so the getters fall back to `global` + {@link emptyPermissions}.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EffectivePermissions, Scope } from "../rights/types.js";
|
|
13
|
+
import type { BasePolicy } from "./BasePolicy.js";
|
|
14
|
+
|
|
15
|
+
/** What the authorizer attaches to a policy instance for one check. */
|
|
16
|
+
export interface PolicyContext {
|
|
17
|
+
readonly scope: Scope;
|
|
18
|
+
readonly permissions: EffectivePermissions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contexts = new WeakMap<BasePolicy, PolicyContext>();
|
|
22
|
+
|
|
23
|
+
/** Attach the active scope + resolved permissions to a policy instance (internal). */
|
|
24
|
+
export function setPolicyContext(
|
|
25
|
+
policy: BasePolicy,
|
|
26
|
+
context: PolicyContext,
|
|
27
|
+
): void {
|
|
28
|
+
contexts.set(policy, context);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read back a policy's attached context, or `undefined` when used standalone. */
|
|
32
|
+
export function getPolicyContext(
|
|
33
|
+
policy: BasePolicy,
|
|
34
|
+
): PolicyContext | undefined {
|
|
35
|
+
return contexts.get(policy);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The empty `EffectivePermissions` for a guest / no-resolver Bouncer (D9). Built
|
|
40
|
+
* locally — never a `resolve()` call (the resolver requires a non-null user) and
|
|
41
|
+
* never importing 56.1's non-exported `ResolvedPermissions`. `has` → false;
|
|
42
|
+
* `hasAll([])` → true (vacuous); `hasAny` → false; empty sets; `scope` = active.
|
|
43
|
+
*/
|
|
44
|
+
export function emptyPermissions(scope: Scope): EffectivePermissions {
|
|
45
|
+
return {
|
|
46
|
+
has: () => false,
|
|
47
|
+
hasAll: (permissions) => permissions.length === 0,
|
|
48
|
+
hasAny: () => false,
|
|
49
|
+
permissions: new Set<string>(),
|
|
50
|
+
roles: new Set<string>(),
|
|
51
|
+
scope,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bouncer evaluation contract — Layer 2 of Warden's unified authorization.
|
|
3
|
+
*
|
|
4
|
+
* The shapes here are faithful to AdonisJS Bouncer (verified context7
|
|
5
|
+
* `/adonisjs/bouncer`, 2026-06-01): a predicate returns a boolean (sugar for
|
|
6
|
+
* allow / deny) or an explicit `AuthorizationResponse`, sync or async.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { UserPayload } from "../AuthManager.js";
|
|
10
|
+
import type { RightsResolver } from "../rights/RightsResolver.js";
|
|
11
|
+
import type { Scope } from "../rights/types.js";
|
|
12
|
+
import type { AuthorizationResponse } from "./AuthorizationResponse.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Optional 4th `Bouncer` ctor argument (56.3, D1) — the scope dimension + the
|
|
16
|
+
* Layer-1 resolver. Both optional; omitting it ⇒ the implicit `global` scope
|
|
17
|
+
* with no resolver (single-tenant zero-config, D7). Additive: every 56.2 call
|
|
18
|
+
* site keeps compiling.
|
|
19
|
+
*/
|
|
20
|
+
export interface BouncerContext {
|
|
21
|
+
/** Active resolution scope. Defaults to `"global"`. */
|
|
22
|
+
readonly scope?: Scope;
|
|
23
|
+
/** Layer-1 resolver consulted for this Bouncer's `(user, scope)`. */
|
|
24
|
+
readonly resolver?: RightsResolver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A predicate's return — bool sugar or an explicit response, sync or async (D7). */
|
|
28
|
+
export type AuthorizerResponse =
|
|
29
|
+
| boolean
|
|
30
|
+
| AuthorizationResponse
|
|
31
|
+
| Promise<boolean | AuthorizationResponse>;
|
|
32
|
+
|
|
33
|
+
/** A `before`/`after` hook return — like {@link AuthorizerResponse} plus `undefined` (fall-through). */
|
|
34
|
+
export type HookResponse =
|
|
35
|
+
| boolean
|
|
36
|
+
| AuthorizationResponse
|
|
37
|
+
| undefined
|
|
38
|
+
| Promise<boolean | AuthorizationResponse | undefined>;
|
|
39
|
+
|
|
40
|
+
/** Options accepted by `Bouncer.ability` and the `@action` decorator. */
|
|
41
|
+
export interface AbilityOptions {
|
|
42
|
+
allowGuest?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An opaque ability reference produced by `Bouncer.ability`. Usable by-reference
|
|
47
|
+
* (typed call args via the `Args` phantom) AND by-name (registered in the
|
|
48
|
+
* Bouncer ctor's abilities record).
|
|
49
|
+
*
|
|
50
|
+
* `Args` is carried by the contravariant `__args` phantom — never read at
|
|
51
|
+
* runtime — so a concrete `Ability<[Post]>` is assignable to the universal
|
|
52
|
+
* `Ability<never[]>` storage type while `execute` stays invokable with the real
|
|
53
|
+
* args. This is a branding pattern (carry the type parameter in the signature),
|
|
54
|
+
* not a cast: `execute` itself takes `unknown[]` and the call sites validate
|
|
55
|
+
* args via the phantom at the verb boundary.
|
|
56
|
+
*/
|
|
57
|
+
export interface Ability<Args extends unknown[] = unknown[]> {
|
|
58
|
+
/** Whether a guest (null user) may invoke the callback (D5 step 2). */
|
|
59
|
+
readonly allowGuest: boolean;
|
|
60
|
+
/** Runs the ability callback. Guest-deny is applied by the evaluator, not here. */
|
|
61
|
+
readonly execute: (
|
|
62
|
+
user: UserPayload | null,
|
|
63
|
+
...args: unknown[]
|
|
64
|
+
) => AuthorizerResponse;
|
|
65
|
+
/** Phantom — ties the by-reference call args to `Args`; never read at runtime. */
|
|
66
|
+
readonly __args?: (...args: Args) => void;
|
|
67
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warden configuration — declared in `config/auth.ts` of the user app.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* // config/auth.ts
|
|
6
|
+
* import { defineConfig } from '@c9up/warden/config'
|
|
7
|
+
* export default defineConfig({
|
|
8
|
+
* defaultStrategy: 'jwt',
|
|
9
|
+
* jwt: {
|
|
10
|
+
* secret: env('APP_KEY'),
|
|
11
|
+
* expiresInSeconds: 3600,
|
|
12
|
+
* findUser: (id) => User.find(id),
|
|
13
|
+
* verifyCredentials: (email, password) => User.verifyCredentials(email, password),
|
|
14
|
+
* },
|
|
15
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { UserPayload } from "./AuthManager.js";
|
|
19
|
+
import type { RightsStore } from "./rights/types.js";
|
|
20
|
+
|
|
21
|
+
export interface JwtConfig {
|
|
22
|
+
secret: string;
|
|
23
|
+
expiresInSeconds?: number;
|
|
24
|
+
findUser: (id: string) => Promise<UserPayload | null>;
|
|
25
|
+
verifyCredentials: (
|
|
26
|
+
email: string,
|
|
27
|
+
password: string,
|
|
28
|
+
) => Promise<UserPayload | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WardenConfig {
|
|
32
|
+
/** Default auth strategy (default: 'jwt'). */
|
|
33
|
+
defaultStrategy?: string;
|
|
34
|
+
/** JWT strategy configuration. */
|
|
35
|
+
jwt?: JwtConfig;
|
|
36
|
+
/**
|
|
37
|
+
* Rights layer configuration (Epic 56). Supply a custom `store` to back the
|
|
38
|
+
* unified resolver with a DB-backed driver; when omitted, an in-memory
|
|
39
|
+
* `MemoryRightsStore` is registered as the default (AD5 — pluggable store,
|
|
40
|
+
* in-memory shipped).
|
|
41
|
+
*/
|
|
42
|
+
rights?: { store?: RightsStore };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Typed config helper — identity function for editor inference. */
|
|
46
|
+
export function defineConfig(config: WardenConfig): WardenConfig {
|
|
47
|
+
return config;
|
|
48
|
+
}
|
package/src/configure.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
interface Codemods {
|
|
4
|
+
addProvider(importPath: string): Promise<void>;
|
|
5
|
+
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
6
|
+
writeFile(
|
|
7
|
+
filePath: string,
|
|
8
|
+
content: string,
|
|
9
|
+
options?: { force?: boolean },
|
|
10
|
+
): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function configure(codemods: Codemods): Promise<void> {
|
|
14
|
+
const jwtSecret = randomBytes(32).toString("hex");
|
|
15
|
+
await codemods.addProvider("@c9up/warden/provider");
|
|
16
|
+
await codemods.addEnvVars({
|
|
17
|
+
JWT_SECRET: jwtSecret,
|
|
18
|
+
JWT_EXPIRY: "3600",
|
|
19
|
+
});
|
|
20
|
+
await codemods.writeFile(
|
|
21
|
+
"config/auth.ts",
|
|
22
|
+
`import { defineConfig } from '@c9up/warden'
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
defaultStrategy: 'jwt',
|
|
26
|
+
jwt: {
|
|
27
|
+
secret: process.env.JWT_SECRET ?? '',
|
|
28
|
+
expiresInSeconds: Number(process.env.JWT_EXPIRY ?? '3600'),
|
|
29
|
+
// TODO: wire these to your user model (e.g. via your ORM).
|
|
30
|
+
// The JWT strategy needs both to issue and verify tokens.
|
|
31
|
+
findUser: async (_id) => {
|
|
32
|
+
throw new Error('TODO: implement findUser(id) for the JWT strategy in config/auth.ts')
|
|
33
|
+
},
|
|
34
|
+
verifyCredentials: async (_email, _password) => {
|
|
35
|
+
throw new Error('TODO: implement verifyCredentials(email, password) in config/auth.ts')
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
`,
|
|
40
|
+
);
|
|
41
|
+
// Announce the TODOs on stderr so `ream add @c9up/warden` doesn't end
|
|
42
|
+
// with a quiet success that the user reads as "auth is wired". The
|
|
43
|
+
// generated config/auth.ts ships with `throw new Error('TODO ...')`
|
|
44
|
+
// stubs for findUser + verifyCredentials — login and JWT verify will
|
|
45
|
+
// fail at the first call until the user fills them in. Surfacing the
|
|
46
|
+
// list here means an operator running the installer sees it
|
|
47
|
+
// immediately, not at the first request that hits the auth path.
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
[
|
|
50
|
+
"",
|
|
51
|
+
"[@c9up/warden] config/auth.ts written with TODO stubs:",
|
|
52
|
+
" - jwt.findUser(id) — wire to your user lookup (ORM)",
|
|
53
|
+
" - jwt.verifyCredentials(email, password) — wire to your sign-in flow",
|
|
54
|
+
"Both throw at runtime until you implement them — login + JWT verify",
|
|
55
|
+
"will fail with `TODO: implement …` errors otherwise.",
|
|
56
|
+
"",
|
|
57
|
+
].join("\n"),
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WardenError — structured error for Warden auth.
|
|
3
|
+
*/
|
|
4
|
+
export class WardenError extends Error {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly hint?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Optional HTTP status carried by the error so the HTTP layer can map it
|
|
9
|
+
* (e.g. an authorization failure carries 403 — Epic 56 / story 56.2, D2).
|
|
10
|
+
* Additive and optional: callers that omit it behave exactly as before.
|
|
11
|
+
*/
|
|
12
|
+
readonly status?: number;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
code: string,
|
|
16
|
+
message: string,
|
|
17
|
+
options?: { hint?: string; status?: number },
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "WardenError";
|
|
21
|
+
this.code = `WARDEN_${code}`;
|
|
22
|
+
this.hint = options?.hint;
|
|
23
|
+
this.status = options?.status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FirstContactManager — OAuth2 social authentication.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* firstContact.use('google').redirectUrl()
|
|
6
|
+
* firstContact.use('google').callback(code)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FirstContactDriver, OAuthToken, OAuthUser } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export class FirstContactManager {
|
|
12
|
+
#drivers: Map<string, FirstContactDriver> = new Map();
|
|
13
|
+
|
|
14
|
+
use(name: string): FirstContactDriver {
|
|
15
|
+
const driver = this.#drivers.get(name);
|
|
16
|
+
if (!driver)
|
|
17
|
+
throw new Error(
|
|
18
|
+
`OAuth driver '${name}' not registered. Available: ${[...this.#drivers.keys()].join(", ")}`,
|
|
19
|
+
);
|
|
20
|
+
return driver;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
register(name: string, driver: FirstContactDriver): void {
|
|
24
|
+
this.#drivers.set(name, driver);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
redirect(name: string, state?: string): string {
|
|
28
|
+
return this.use(name).redirectUrl(state);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Handle the OAuth callback. Pass `state` (from the query string) and
|
|
33
|
+
* `expectedState` (from the session, stored at redirect time) for CSRF
|
|
34
|
+
* protection. Omitting `expectedState` logs a security warning.
|
|
35
|
+
*/
|
|
36
|
+
async callback(
|
|
37
|
+
name: string,
|
|
38
|
+
code: string,
|
|
39
|
+
state?: string,
|
|
40
|
+
expectedState?: string,
|
|
41
|
+
): Promise<{ user: OAuthUser; token: OAuthToken }> {
|
|
42
|
+
if (!expectedState) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[warden] OAuth callback for '${name}' requires expectedState for CSRF protection. ` +
|
|
45
|
+
`Store the state from redirect() in the session and pass it here.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return this.use(name).callback(code, state, expectedState);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get registeredDrivers(): string[] {
|
|
52
|
+
return [...this.#drivers.keys()];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth2 driver for FirstContact.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
FirstContactDriver,
|
|
7
|
+
OAuthConfig,
|
|
8
|
+
OAuthToken,
|
|
9
|
+
OAuthUser,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
export class GitHubDriver implements FirstContactDriver {
|
|
13
|
+
constructor(private config: OAuthConfig) {}
|
|
14
|
+
|
|
15
|
+
redirectUrl(state?: string): string {
|
|
16
|
+
const params = new URLSearchParams({
|
|
17
|
+
client_id: this.config.clientId,
|
|
18
|
+
redirect_uri: this.config.callbackUrl,
|
|
19
|
+
scope: (this.config.scopes ?? ["read:user", "user:email"]).join(" "),
|
|
20
|
+
...(state ? { state } : {}),
|
|
21
|
+
});
|
|
22
|
+
return `https://github.com/login/oauth/authorize?${params}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async callback(
|
|
26
|
+
code: string,
|
|
27
|
+
state?: string,
|
|
28
|
+
expectedState?: string,
|
|
29
|
+
): Promise<{ user: OAuthUser; token: OAuthToken }> {
|
|
30
|
+
if (expectedState && state !== expectedState) {
|
|
31
|
+
throw new Error("OAuth state mismatch — possible CSRF attack");
|
|
32
|
+
}
|
|
33
|
+
const tokenRes = await fetch(
|
|
34
|
+
"https://github.com/login/oauth/access_token",
|
|
35
|
+
{
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
Accept: "application/json",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
client_id: this.config.clientId,
|
|
43
|
+
client_secret: this.config.clientSecret,
|
|
44
|
+
code,
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
if (!tokenRes.ok) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`GitHub OAuth token exchange failed (HTTP ${tokenRes.status})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const tokens = (await tokenRes.json()) as Record<string, unknown>;
|
|
54
|
+
if (!tokens.access_token)
|
|
55
|
+
throw new Error("GitHub OAuth: no access_token in response");
|
|
56
|
+
|
|
57
|
+
const userRes = await fetch("https://api.github.com/user", {
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
60
|
+
Accept: "application/json",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (!userRes.ok)
|
|
64
|
+
throw new Error(`GitHub user API failed (${userRes.status})`);
|
|
65
|
+
const raw = (await userRes.json()) as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
user: {
|
|
69
|
+
id: String(raw.id ?? ""),
|
|
70
|
+
email: String(raw.email ?? ""),
|
|
71
|
+
name: String(raw.name ?? raw.login ?? ""),
|
|
72
|
+
avatarUrl: raw.avatar_url as string | undefined,
|
|
73
|
+
raw,
|
|
74
|
+
},
|
|
75
|
+
token: { accessToken: String(tokens.access_token) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth2 driver for FirstContact.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
FirstContactDriver,
|
|
7
|
+
OAuthConfig,
|
|
8
|
+
OAuthToken,
|
|
9
|
+
OAuthUser,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
export class GoogleDriver implements FirstContactDriver {
|
|
13
|
+
constructor(private config: OAuthConfig) {}
|
|
14
|
+
|
|
15
|
+
redirectUrl(state?: string): string {
|
|
16
|
+
const params = new URLSearchParams({
|
|
17
|
+
client_id: this.config.clientId,
|
|
18
|
+
redirect_uri: this.config.callbackUrl,
|
|
19
|
+
response_type: "code",
|
|
20
|
+
scope: (this.config.scopes ?? ["openid", "email", "profile"]).join(" "),
|
|
21
|
+
access_type: "offline",
|
|
22
|
+
...(state ? { state } : {}),
|
|
23
|
+
});
|
|
24
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async callback(
|
|
28
|
+
code: string,
|
|
29
|
+
state?: string,
|
|
30
|
+
expectedState?: string,
|
|
31
|
+
): Promise<{ user: OAuthUser; token: OAuthToken }> {
|
|
32
|
+
// CSRF protection: validate state matches what we sent in redirectUrl().
|
|
33
|
+
if (expectedState && state !== expectedState) {
|
|
34
|
+
throw new Error("OAuth state mismatch — possible CSRF attack");
|
|
35
|
+
}
|
|
36
|
+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
39
|
+
body: new URLSearchParams({
|
|
40
|
+
code,
|
|
41
|
+
client_id: this.config.clientId,
|
|
42
|
+
client_secret: this.config.clientSecret,
|
|
43
|
+
redirect_uri: this.config.callbackUrl,
|
|
44
|
+
grant_type: "authorization_code",
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
if (!tokenRes.ok) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Google OAuth token exchange failed (HTTP ${tokenRes.status})`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const tokens = (await tokenRes.json()) as Record<string, unknown>;
|
|
53
|
+
if (!tokens.access_token)
|
|
54
|
+
throw new Error("Google OAuth: no access_token in response");
|
|
55
|
+
|
|
56
|
+
const userRes = await fetch(
|
|
57
|
+
"https://www.googleapis.com/oauth2/v2/userinfo",
|
|
58
|
+
{
|
|
59
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
if (!userRes.ok)
|
|
63
|
+
throw new Error(`Google userinfo failed (${userRes.status})`);
|
|
64
|
+
const raw = (await userRes.json()) as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
user: {
|
|
68
|
+
id: String(raw.id ?? ""),
|
|
69
|
+
email: String(raw.email ?? ""),
|
|
70
|
+
name: String(raw.name ?? ""),
|
|
71
|
+
avatarUrl: raw.picture as string | undefined,
|
|
72
|
+
raw,
|
|
73
|
+
},
|
|
74
|
+
token: {
|
|
75
|
+
accessToken: String(tokens.access_token),
|
|
76
|
+
refreshToken: tokens.refresh_token as string | undefined,
|
|
77
|
+
expiresIn: tokens.expires_in as number | undefined,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FirstContact — OAuth2 social authentication types.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface OAuthConfig {
|
|
6
|
+
clientId: string;
|
|
7
|
+
clientSecret: string;
|
|
8
|
+
callbackUrl: string;
|
|
9
|
+
scopes?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OAuthUser {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
name: string;
|
|
16
|
+
avatarUrl?: string;
|
|
17
|
+
raw: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OAuthToken {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
expiresIn?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FirstContactDriver {
|
|
27
|
+
redirectUrl(state?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Handle the OAuth callback. `state` MUST be validated against the value
|
|
30
|
+
* originally passed to `redirectUrl()` to prevent CSRF login attacks.
|
|
31
|
+
* Throws if state is missing or mismatched.
|
|
32
|
+
*/
|
|
33
|
+
callback(
|
|
34
|
+
code: string,
|
|
35
|
+
state?: string,
|
|
36
|
+
expectedState?: string,
|
|
37
|
+
): Promise<{ user: OAuthUser; token: OAuthToken }>;
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @c9up/warden
|
|
3
|
+
* @description Warden — Authentication & authorization for the Ream framework
|
|
4
|
+
* @implements FR48, FR49, FR50, FR51, FR52, FR53
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
AuthConfig,
|
|
9
|
+
AuthResult,
|
|
10
|
+
AuthStrategy,
|
|
11
|
+
UserPayload,
|
|
12
|
+
} from "./AuthManager.js";
|
|
13
|
+
export { AuthManager } from "./AuthManager.js";
|
|
14
|
+
export type { AuthRateLimiterConfig } from "./AuthRateLimiter.js";
|
|
15
|
+
export { AuthRateLimiter } from "./AuthRateLimiter.js";
|
|
16
|
+
export { AuthorizationResponse } from "./bouncer/AuthorizationResponse.js";
|
|
17
|
+
export { BasePolicy } from "./bouncer/BasePolicy.js";
|
|
18
|
+
export { Bouncer } from "./bouncer/Bouncer.js";
|
|
19
|
+
export {
|
|
20
|
+
action,
|
|
21
|
+
allowGuest,
|
|
22
|
+
getActionMetadata,
|
|
23
|
+
} from "./bouncer/decorators.js";
|
|
24
|
+
export type { PolicyAuthorizer } from "./bouncer/PolicyAuthorizer.js";
|
|
25
|
+
export type {
|
|
26
|
+
Ability,
|
|
27
|
+
AbilityOptions,
|
|
28
|
+
AuthorizerResponse,
|
|
29
|
+
BouncerContext,
|
|
30
|
+
} from "./bouncer/types.js";
|
|
31
|
+
export { defineConfig } from "./config.js";
|
|
32
|
+
export { configure } from "./configure.js";
|
|
33
|
+
export { WardenError } from "./errors.js";
|
|
34
|
+
export { GitHubDriver } from "./firstcontact/drivers/GitHubDriver.js";
|
|
35
|
+
export { GoogleDriver } from "./firstcontact/drivers/GoogleDriver.js";
|
|
36
|
+
export { FirstContactManager } from "./firstcontact/FirstContactManager.js";
|
|
37
|
+
export type {
|
|
38
|
+
FirstContactDriver,
|
|
39
|
+
OAuthConfig,
|
|
40
|
+
OAuthToken,
|
|
41
|
+
OAuthUser,
|
|
42
|
+
} from "./firstcontact/types.js";
|
|
43
|
+
export {
|
|
44
|
+
Guard,
|
|
45
|
+
getGuardMetadata,
|
|
46
|
+
getPermissionMetadata,
|
|
47
|
+
getRoleMetadata,
|
|
48
|
+
Permission,
|
|
49
|
+
Role,
|
|
50
|
+
} from "./Guard.js";
|
|
51
|
+
export type {
|
|
52
|
+
RefreshTokenDriver,
|
|
53
|
+
StoredRefreshToken,
|
|
54
|
+
} from "./RefreshTokenStore.js";
|
|
55
|
+
export {
|
|
56
|
+
generateRefreshToken,
|
|
57
|
+
MemoryRefreshTokenDriver,
|
|
58
|
+
} from "./RefreshTokenStore.js";
|
|
59
|
+
export { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
|
|
60
|
+
export { RightsResolver } from "./rights/RightsResolver.js";
|
|
61
|
+
export type {
|
|
62
|
+
EffectivePermissions,
|
|
63
|
+
RightsStore,
|
|
64
|
+
Scope,
|
|
65
|
+
} from "./rights/types.js";
|
|
66
|
+
export { scopeKey } from "./rights/types.js";
|
|
67
|
+
export type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
|
|
68
|
+
export { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
|
|
69
|
+
export type { JwtStrategyConfig } from "./strategies/JwtStrategy.js";
|
|
70
|
+
export { generateJwtSecret, JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
71
|
+
export type {
|
|
72
|
+
SessionStore,
|
|
73
|
+
SessionStrategyConfig,
|
|
74
|
+
} from "./strategies/SessionStrategy.js";
|
|
75
|
+
export { SessionStrategy } from "./strategies/SessionStrategy.js";
|
|
76
|
+
export type { BlacklistDriver } from "./TokenBlacklist.js";
|
|
77
|
+
export { MemoryBlacklistDriver, TokenBlacklist } from "./TokenBlacklist.js";
|