@emdash-cms/auth 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/kysely.d.mts +62 -0
- package/dist/adapters/kysely.d.mts.map +1 -0
- package/dist/adapters/kysely.mjs +379 -0
- package/dist/adapters/kysely.mjs.map +1 -0
- package/dist/authenticate-D5UgaoTH.d.mts +124 -0
- package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
- package/dist/authenticate-j5GayLXB.mjs +373 -0
- package/dist/authenticate-j5GayLXB.mjs.map +1 -0
- package/dist/index.d.mts +444 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +728 -0
- package/dist/index.mjs.map +1 -0
- package/dist/oauth/providers/github.d.mts +12 -0
- package/dist/oauth/providers/github.d.mts.map +1 -0
- package/dist/oauth/providers/github.mjs +55 -0
- package/dist/oauth/providers/github.mjs.map +1 -0
- package/dist/oauth/providers/google.d.mts +7 -0
- package/dist/oauth/providers/google.d.mts.map +1 -0
- package/dist/oauth/providers/google.mjs +38 -0
- package/dist/oauth/providers/google.mjs.map +1 -0
- package/dist/passkey/index.d.mts +2 -0
- package/dist/passkey/index.mjs +3 -0
- package/dist/types-Bu4irX9A.d.mts +35 -0
- package/dist/types-Bu4irX9A.d.mts.map +1 -0
- package/dist/types-CiSNpRI9.mjs +60 -0
- package/dist/types-CiSNpRI9.mjs.map +1 -0
- package/dist/types-HtRc90Wi.d.mts +208 -0
- package/dist/types-HtRc90Wi.d.mts.map +1 -0
- package/package.json +72 -0
- package/src/adapters/kysely.ts +715 -0
- package/src/config.ts +214 -0
- package/src/index.ts +135 -0
- package/src/invite.ts +205 -0
- package/src/magic-link/index.ts +150 -0
- package/src/oauth/consumer.ts +324 -0
- package/src/oauth/providers/github.ts +68 -0
- package/src/oauth/providers/google.ts +34 -0
- package/src/oauth/types.ts +36 -0
- package/src/passkey/authenticate.ts +183 -0
- package/src/passkey/index.ts +27 -0
- package/src/passkey/register.ts +232 -0
- package/src/passkey/types.ts +120 -0
- package/src/rbac.test.ts +141 -0
- package/src/rbac.ts +205 -0
- package/src/signup.ts +210 -0
- package/src/tokens.test.ts +141 -0
- package/src/tokens.ts +238 -0
- package/src/types.ts +352 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey registration (credential creation)
|
|
3
|
+
*
|
|
4
|
+
* Based on oslo webauthn documentation:
|
|
5
|
+
* https://webauthn.oslojs.dev/examples/registration
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa";
|
|
9
|
+
import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding";
|
|
10
|
+
import {
|
|
11
|
+
parseAttestationObject,
|
|
12
|
+
parseClientDataJSON,
|
|
13
|
+
coseAlgorithmES256,
|
|
14
|
+
coseAlgorithmRS256,
|
|
15
|
+
coseEllipticCurveP256,
|
|
16
|
+
ClientDataType,
|
|
17
|
+
AttestationStatementFormat,
|
|
18
|
+
COSEKeyType,
|
|
19
|
+
} from "@oslojs/webauthn";
|
|
20
|
+
|
|
21
|
+
import { generateToken } from "../tokens.js";
|
|
22
|
+
import type { Credential, NewCredential, AuthAdapter, User, DeviceType } from "../types.js";
|
|
23
|
+
import type {
|
|
24
|
+
RegistrationOptions,
|
|
25
|
+
RegistrationResponse,
|
|
26
|
+
VerifiedRegistration,
|
|
27
|
+
ChallengeStore,
|
|
28
|
+
PasskeyConfig,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
|
|
31
|
+
const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
32
|
+
|
|
33
|
+
export type { PasskeyConfig };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate registration options for creating a new passkey
|
|
37
|
+
*/
|
|
38
|
+
export async function generateRegistrationOptions(
|
|
39
|
+
config: PasskeyConfig,
|
|
40
|
+
user: Pick<User, "id" | "email" | "name">,
|
|
41
|
+
existingCredentials: Credential[],
|
|
42
|
+
challengeStore: ChallengeStore,
|
|
43
|
+
): Promise<RegistrationOptions> {
|
|
44
|
+
const challenge = generateToken();
|
|
45
|
+
|
|
46
|
+
// Store challenge for verification
|
|
47
|
+
await challengeStore.set(challenge, {
|
|
48
|
+
type: "registration",
|
|
49
|
+
userId: user.id,
|
|
50
|
+
expiresAt: Date.now() + CHALLENGE_TTL,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Encode user ID as base64url
|
|
54
|
+
const userIdBytes = new TextEncoder().encode(user.id);
|
|
55
|
+
const userIdEncoded = encodeBase64urlNoPadding(userIdBytes);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
challenge,
|
|
59
|
+
rp: {
|
|
60
|
+
name: config.rpName,
|
|
61
|
+
id: config.rpId,
|
|
62
|
+
},
|
|
63
|
+
user: {
|
|
64
|
+
id: userIdEncoded,
|
|
65
|
+
name: user.email,
|
|
66
|
+
displayName: user.name || user.email,
|
|
67
|
+
},
|
|
68
|
+
pubKeyCredParams: [
|
|
69
|
+
{ type: "public-key", alg: coseAlgorithmES256 }, // ES256 (-7)
|
|
70
|
+
{ type: "public-key", alg: coseAlgorithmRS256 }, // RS256 (-257)
|
|
71
|
+
],
|
|
72
|
+
timeout: 60000,
|
|
73
|
+
attestation: "none", // We don't need attestation for our use case
|
|
74
|
+
authenticatorSelection: {
|
|
75
|
+
residentKey: "preferred", // Allow discoverable credentials
|
|
76
|
+
userVerification: "preferred",
|
|
77
|
+
},
|
|
78
|
+
excludeCredentials: existingCredentials.map((cred) => ({
|
|
79
|
+
type: "public-key" as const,
|
|
80
|
+
id: cred.id,
|
|
81
|
+
transports: cred.transports,
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify a registration response and extract credential data
|
|
88
|
+
*/
|
|
89
|
+
export async function verifyRegistrationResponse(
|
|
90
|
+
config: PasskeyConfig,
|
|
91
|
+
response: RegistrationResponse,
|
|
92
|
+
challengeStore: ChallengeStore,
|
|
93
|
+
): Promise<VerifiedRegistration> {
|
|
94
|
+
// Decode the response
|
|
95
|
+
const clientDataJSON = decodeBase64urlIgnorePadding(response.response.clientDataJSON);
|
|
96
|
+
const attestationObject = decodeBase64urlIgnorePadding(response.response.attestationObject);
|
|
97
|
+
|
|
98
|
+
// Parse client data
|
|
99
|
+
const clientData = parseClientDataJSON(clientDataJSON);
|
|
100
|
+
|
|
101
|
+
// Verify client data
|
|
102
|
+
if (clientData.type !== ClientDataType.Create) {
|
|
103
|
+
throw new Error("Invalid client data type");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format)
|
|
107
|
+
const challengeString = encodeBase64urlNoPadding(clientData.challenge);
|
|
108
|
+
const challengeData = await challengeStore.get(challengeString);
|
|
109
|
+
if (!challengeData) {
|
|
110
|
+
throw new Error("Challenge not found or expired");
|
|
111
|
+
}
|
|
112
|
+
if (challengeData.type !== "registration") {
|
|
113
|
+
throw new Error("Invalid challenge type");
|
|
114
|
+
}
|
|
115
|
+
if (challengeData.expiresAt < Date.now()) {
|
|
116
|
+
await challengeStore.delete(challengeString);
|
|
117
|
+
throw new Error("Challenge expired");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Delete challenge (single-use)
|
|
121
|
+
await challengeStore.delete(challengeString);
|
|
122
|
+
|
|
123
|
+
// Verify origin
|
|
124
|
+
if (clientData.origin !== config.origin) {
|
|
125
|
+
throw new Error(`Invalid origin: expected ${config.origin}, got ${clientData.origin}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Parse attestation object
|
|
129
|
+
const attestation = parseAttestationObject(attestationObject);
|
|
130
|
+
|
|
131
|
+
// We only support 'none' attestation for simplicity
|
|
132
|
+
if (attestation.attestationStatement.format !== AttestationStatementFormat.None) {
|
|
133
|
+
// For other formats, we'd need to verify the attestation statement
|
|
134
|
+
// For now, we just ignore it and trust the credential
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { authenticatorData } = attestation;
|
|
138
|
+
|
|
139
|
+
// Verify RP ID hash
|
|
140
|
+
if (!authenticatorData.verifyRelyingPartyIdHash(config.rpId)) {
|
|
141
|
+
throw new Error("Invalid RP ID hash");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Verify flags
|
|
145
|
+
if (!authenticatorData.userPresent) {
|
|
146
|
+
throw new Error("User presence not verified");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Extract credential data
|
|
150
|
+
if (!authenticatorData.credential) {
|
|
151
|
+
throw new Error("No credential data in attestation");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { credential } = authenticatorData;
|
|
155
|
+
|
|
156
|
+
// Verify algorithm is supported and encode public key
|
|
157
|
+
// Currently only supporting ES256 (ECDSA with P-256)
|
|
158
|
+
const algorithm = credential.publicKey.algorithm();
|
|
159
|
+
let encodedPublicKey: Uint8Array;
|
|
160
|
+
|
|
161
|
+
if (algorithm === coseAlgorithmES256) {
|
|
162
|
+
// Verify it's EC2 key type
|
|
163
|
+
if (credential.publicKey.type() !== COSEKeyType.EC2) {
|
|
164
|
+
throw new Error("Expected EC2 key type for ES256");
|
|
165
|
+
}
|
|
166
|
+
const cosePublicKey = credential.publicKey.ec2();
|
|
167
|
+
if (cosePublicKey.curve !== coseEllipticCurveP256) {
|
|
168
|
+
throw new Error("Expected P-256 curve for ES256");
|
|
169
|
+
}
|
|
170
|
+
// Encode as SEC1 uncompressed format for storage
|
|
171
|
+
encodedPublicKey = new ECDSAPublicKey(
|
|
172
|
+
p256,
|
|
173
|
+
cosePublicKey.x,
|
|
174
|
+
cosePublicKey.y,
|
|
175
|
+
).encodeSEC1Uncompressed();
|
|
176
|
+
} else if (algorithm === coseAlgorithmRS256) {
|
|
177
|
+
// RSA is less common for passkeys, skip for now
|
|
178
|
+
throw new Error("RS256 not yet supported - please use ES256");
|
|
179
|
+
} else {
|
|
180
|
+
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Determine device type and backup status
|
|
184
|
+
// Note: oslo webauthn doesn't expose backup flags, so we default to singleDevice
|
|
185
|
+
// In practice, most modern passkeys are multi-device (e.g., iCloud Keychain, Google Password Manager)
|
|
186
|
+
const deviceType: DeviceType = "singleDevice";
|
|
187
|
+
const backedUp = false;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
credentialId: response.id,
|
|
191
|
+
publicKey: encodedPublicKey,
|
|
192
|
+
counter: authenticatorData.signatureCounter,
|
|
193
|
+
deviceType,
|
|
194
|
+
backedUp,
|
|
195
|
+
transports: response.response.transports ?? [],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Register a new passkey for a user
|
|
201
|
+
*/
|
|
202
|
+
export async function registerPasskey(
|
|
203
|
+
adapter: AuthAdapter,
|
|
204
|
+
userId: string,
|
|
205
|
+
verified: VerifiedRegistration,
|
|
206
|
+
name?: string,
|
|
207
|
+
): Promise<Credential> {
|
|
208
|
+
// Check credential limit
|
|
209
|
+
const count = await adapter.countCredentialsByUserId(userId);
|
|
210
|
+
if (count >= 10) {
|
|
211
|
+
throw new Error("Maximum number of passkeys reached (10)");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check if credential already exists
|
|
215
|
+
const existing = await adapter.getCredentialById(verified.credentialId);
|
|
216
|
+
if (existing) {
|
|
217
|
+
throw new Error("Credential already registered");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const newCredential: NewCredential = {
|
|
221
|
+
id: verified.credentialId,
|
|
222
|
+
userId,
|
|
223
|
+
publicKey: verified.publicKey,
|
|
224
|
+
counter: verified.counter,
|
|
225
|
+
deviceType: verified.deviceType,
|
|
226
|
+
backedUp: verified.backedUp,
|
|
227
|
+
transports: verified.transports,
|
|
228
|
+
name,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return adapter.createCredential(newCredential);
|
|
232
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn types for passkey authentication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AuthenticatorTransport, DeviceType } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Registration (Creating a new passkey)
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface RegistrationOptions {
|
|
12
|
+
challenge: string; // Base64url encoded
|
|
13
|
+
rp: {
|
|
14
|
+
name: string;
|
|
15
|
+
id: string;
|
|
16
|
+
};
|
|
17
|
+
user: {
|
|
18
|
+
id: string; // Base64url encoded user ID
|
|
19
|
+
name: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
};
|
|
22
|
+
pubKeyCredParams: Array<{
|
|
23
|
+
type: "public-key";
|
|
24
|
+
alg: number; // COSE algorithm identifier
|
|
25
|
+
}>;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
attestation?: "none" | "indirect" | "direct";
|
|
28
|
+
authenticatorSelection?: {
|
|
29
|
+
authenticatorAttachment?: "platform" | "cross-platform";
|
|
30
|
+
residentKey?: "discouraged" | "preferred" | "required";
|
|
31
|
+
requireResidentKey?: boolean;
|
|
32
|
+
userVerification?: "discouraged" | "preferred" | "required";
|
|
33
|
+
};
|
|
34
|
+
excludeCredentials?: Array<{
|
|
35
|
+
type: "public-key";
|
|
36
|
+
id: string; // Base64url encoded credential ID
|
|
37
|
+
transports?: AuthenticatorTransport[];
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RegistrationResponse {
|
|
42
|
+
id: string; // Base64url credential ID
|
|
43
|
+
rawId: string; // Base64url
|
|
44
|
+
type: "public-key";
|
|
45
|
+
response: {
|
|
46
|
+
clientDataJSON: string; // Base64url
|
|
47
|
+
attestationObject: string; // Base64url
|
|
48
|
+
transports?: AuthenticatorTransport[];
|
|
49
|
+
};
|
|
50
|
+
authenticatorAttachment?: "platform" | "cross-platform";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface VerifiedRegistration {
|
|
54
|
+
credentialId: string;
|
|
55
|
+
publicKey: Uint8Array;
|
|
56
|
+
counter: number;
|
|
57
|
+
deviceType: DeviceType;
|
|
58
|
+
backedUp: boolean;
|
|
59
|
+
transports: AuthenticatorTransport[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// Authentication (Using an existing passkey)
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
export interface AuthenticationOptions {
|
|
67
|
+
challenge: string; // Base64url encoded
|
|
68
|
+
rpId: string;
|
|
69
|
+
timeout?: number;
|
|
70
|
+
userVerification?: "discouraged" | "preferred" | "required";
|
|
71
|
+
allowCredentials?: Array<{
|
|
72
|
+
type: "public-key";
|
|
73
|
+
id: string; // Base64url encoded credential ID
|
|
74
|
+
transports?: AuthenticatorTransport[];
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AuthenticationResponse {
|
|
79
|
+
id: string; // Base64url credential ID
|
|
80
|
+
rawId: string; // Base64url
|
|
81
|
+
type: "public-key";
|
|
82
|
+
response: {
|
|
83
|
+
clientDataJSON: string; // Base64url
|
|
84
|
+
authenticatorData: string; // Base64url
|
|
85
|
+
signature: string; // Base64url
|
|
86
|
+
userHandle?: string; // Base64url (user ID)
|
|
87
|
+
};
|
|
88
|
+
authenticatorAttachment?: "platform" | "cross-platform";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface VerifiedAuthentication {
|
|
92
|
+
credentialId: string;
|
|
93
|
+
newCounter: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Challenge storage
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
export interface ChallengeStore {
|
|
101
|
+
set(challenge: string, data: ChallengeData): Promise<void>;
|
|
102
|
+
get(challenge: string): Promise<ChallengeData | null>;
|
|
103
|
+
delete(challenge: string): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ChallengeData {
|
|
107
|
+
type: "registration" | "authentication";
|
|
108
|
+
userId?: string; // For registration, the user being registered
|
|
109
|
+
expiresAt: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Passkey Configuration
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
export interface PasskeyConfig {
|
|
117
|
+
rpName: string;
|
|
118
|
+
rpId: string;
|
|
119
|
+
origin: string;
|
|
120
|
+
}
|
package/src/rbac.test.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
hasPermission,
|
|
5
|
+
requirePermission,
|
|
6
|
+
canActOnOwn,
|
|
7
|
+
requirePermissionOnResource,
|
|
8
|
+
PermissionError,
|
|
9
|
+
} from "./rbac.js";
|
|
10
|
+
import { Role } from "./types.js";
|
|
11
|
+
|
|
12
|
+
describe("rbac", () => {
|
|
13
|
+
describe("hasPermission", () => {
|
|
14
|
+
it("returns false for null user", () => {
|
|
15
|
+
expect(hasPermission(null, "content:read")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns false for undefined user", () => {
|
|
19
|
+
expect(hasPermission(undefined, "content:read")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("allows subscriber to read content", () => {
|
|
23
|
+
expect(hasPermission({ role: Role.SUBSCRIBER }, "content:read")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("denies subscriber from creating content", () => {
|
|
27
|
+
expect(hasPermission({ role: Role.SUBSCRIBER }, "content:create")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("allows contributor to create content", () => {
|
|
31
|
+
expect(hasPermission({ role: Role.CONTRIBUTOR }, "content:create")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("allows admin to do anything", () => {
|
|
35
|
+
const admin = { role: Role.ADMIN };
|
|
36
|
+
expect(hasPermission(admin, "content:read")).toBe(true);
|
|
37
|
+
expect(hasPermission(admin, "content:create")).toBe(true);
|
|
38
|
+
expect(hasPermission(admin, "users:manage")).toBe(true);
|
|
39
|
+
expect(hasPermission(admin, "schema:manage")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("denies editor from managing users", () => {
|
|
43
|
+
expect(hasPermission({ role: Role.EDITOR }, "users:manage")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("allows author to edit own media", () => {
|
|
47
|
+
expect(hasPermission({ role: Role.AUTHOR }, "media:edit_own")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("denies contributor from editing media", () => {
|
|
51
|
+
expect(hasPermission({ role: Role.CONTRIBUTOR }, "media:edit_own")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("allows editor to edit any media", () => {
|
|
55
|
+
expect(hasPermission({ role: Role.EDITOR }, "media:edit_any")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("denies author from editing any media", () => {
|
|
59
|
+
expect(hasPermission({ role: Role.AUTHOR }, "media:edit_any")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("requirePermission", () => {
|
|
64
|
+
it("throws for null user", () => {
|
|
65
|
+
expect(() => requirePermission(null, "content:read")).toThrow(PermissionError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws unauthorized for missing user", () => {
|
|
69
|
+
try {
|
|
70
|
+
requirePermission(null, "content:read");
|
|
71
|
+
} catch (e) {
|
|
72
|
+
expect(e).toBeInstanceOf(PermissionError);
|
|
73
|
+
expect((e as PermissionError).code).toBe("unauthorized");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws forbidden for insufficient permissions", () => {
|
|
78
|
+
try {
|
|
79
|
+
requirePermission({ role: Role.SUBSCRIBER }, "content:create");
|
|
80
|
+
} catch (e) {
|
|
81
|
+
expect(e).toBeInstanceOf(PermissionError);
|
|
82
|
+
expect((e as PermissionError).code).toBe("forbidden");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not throw for sufficient permissions", () => {
|
|
87
|
+
expect(() => requirePermission({ role: Role.ADMIN }, "content:create")).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("canActOnOwn", () => {
|
|
92
|
+
const user = { role: Role.AUTHOR, id: "user-1" };
|
|
93
|
+
|
|
94
|
+
it("allows action on own resource with own permission", () => {
|
|
95
|
+
expect(canActOnOwn(user, "user-1", "content:edit_own", "content:edit_any")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("denies action on others resource without any permission", () => {
|
|
99
|
+
expect(canActOnOwn(user, "user-2", "content:edit_own", "content:edit_any")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("allows editor to edit any resource", () => {
|
|
103
|
+
const editor = { role: Role.EDITOR, id: "editor-1" };
|
|
104
|
+
expect(canActOnOwn(editor, "user-2", "content:edit_own", "content:edit_any")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("allows author to edit own media", () => {
|
|
108
|
+
expect(canActOnOwn(user, "user-1", "media:edit_own", "media:edit_any")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("denies author from editing others media", () => {
|
|
112
|
+
expect(canActOnOwn(user, "user-2", "media:edit_own", "media:edit_any")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("denies contributor from editing any media (including own)", () => {
|
|
116
|
+
const contributor = { role: Role.CONTRIBUTOR, id: "contrib-1" };
|
|
117
|
+
expect(canActOnOwn(contributor, "contrib-1", "media:edit_own", "media:edit_any")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("allows editor to edit any media", () => {
|
|
121
|
+
const editor = { role: Role.EDITOR, id: "editor-1" };
|
|
122
|
+
expect(canActOnOwn(editor, "user-2", "media:edit_own", "media:edit_any")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("requirePermissionOnResource", () => {
|
|
127
|
+
it("allows author to edit own content", () => {
|
|
128
|
+
const user = { role: Role.AUTHOR, id: "user-1" };
|
|
129
|
+
expect(() =>
|
|
130
|
+
requirePermissionOnResource(user, "user-1", "content:edit_own", "content:edit_any"),
|
|
131
|
+
).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("throws for author editing others content", () => {
|
|
135
|
+
const user = { role: Role.AUTHOR, id: "user-1" };
|
|
136
|
+
expect(() =>
|
|
137
|
+
requirePermissionOnResource(user, "user-2", "content:edit_own", "content:edit_any"),
|
|
138
|
+
).toThrow(PermissionError);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
package/src/rbac.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-Based Access Control
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ApiTokenScope } from "./tokens.js";
|
|
6
|
+
import { Role, type RoleLevel } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Permission definitions with minimum role required
|
|
10
|
+
*/
|
|
11
|
+
export const Permissions = {
|
|
12
|
+
// Content
|
|
13
|
+
"content:read": Role.SUBSCRIBER,
|
|
14
|
+
"content:create": Role.CONTRIBUTOR,
|
|
15
|
+
"content:edit_own": Role.AUTHOR,
|
|
16
|
+
"content:edit_any": Role.EDITOR,
|
|
17
|
+
"content:delete_own": Role.AUTHOR,
|
|
18
|
+
"content:delete_any": Role.EDITOR,
|
|
19
|
+
"content:publish_own": Role.AUTHOR,
|
|
20
|
+
"content:publish_any": Role.EDITOR,
|
|
21
|
+
|
|
22
|
+
// Media
|
|
23
|
+
"media:read": Role.SUBSCRIBER,
|
|
24
|
+
"media:upload": Role.CONTRIBUTOR,
|
|
25
|
+
"media:edit_own": Role.AUTHOR,
|
|
26
|
+
"media:edit_any": Role.EDITOR,
|
|
27
|
+
"media:delete_own": Role.AUTHOR,
|
|
28
|
+
"media:delete_any": Role.EDITOR,
|
|
29
|
+
|
|
30
|
+
// Taxonomies
|
|
31
|
+
"taxonomies:read": Role.SUBSCRIBER,
|
|
32
|
+
"taxonomies:manage": Role.EDITOR,
|
|
33
|
+
|
|
34
|
+
// Comments
|
|
35
|
+
"comments:read": Role.SUBSCRIBER,
|
|
36
|
+
"comments:moderate": Role.EDITOR,
|
|
37
|
+
"comments:delete": Role.ADMIN,
|
|
38
|
+
"comments:settings": Role.ADMIN,
|
|
39
|
+
|
|
40
|
+
// Menus
|
|
41
|
+
"menus:read": Role.SUBSCRIBER,
|
|
42
|
+
"menus:manage": Role.EDITOR,
|
|
43
|
+
|
|
44
|
+
// Widgets
|
|
45
|
+
"widgets:read": Role.SUBSCRIBER,
|
|
46
|
+
"widgets:manage": Role.EDITOR,
|
|
47
|
+
|
|
48
|
+
// Sections
|
|
49
|
+
"sections:read": Role.SUBSCRIBER,
|
|
50
|
+
"sections:manage": Role.EDITOR,
|
|
51
|
+
|
|
52
|
+
// Redirects
|
|
53
|
+
"redirects:read": Role.EDITOR,
|
|
54
|
+
"redirects:manage": Role.ADMIN,
|
|
55
|
+
|
|
56
|
+
// Users
|
|
57
|
+
"users:read": Role.ADMIN,
|
|
58
|
+
"users:invite": Role.ADMIN,
|
|
59
|
+
"users:manage": Role.ADMIN,
|
|
60
|
+
|
|
61
|
+
// Settings
|
|
62
|
+
"settings:read": Role.EDITOR,
|
|
63
|
+
"settings:manage": Role.ADMIN,
|
|
64
|
+
|
|
65
|
+
// Schema (content types)
|
|
66
|
+
"schema:read": Role.EDITOR,
|
|
67
|
+
"schema:manage": Role.ADMIN,
|
|
68
|
+
|
|
69
|
+
// Plugins
|
|
70
|
+
"plugins:read": Role.EDITOR,
|
|
71
|
+
"plugins:manage": Role.ADMIN,
|
|
72
|
+
|
|
73
|
+
// Import
|
|
74
|
+
"import:execute": Role.ADMIN,
|
|
75
|
+
|
|
76
|
+
// Search
|
|
77
|
+
"search:read": Role.SUBSCRIBER,
|
|
78
|
+
"search:manage": Role.ADMIN,
|
|
79
|
+
|
|
80
|
+
// Auth
|
|
81
|
+
"auth:manage_own_credentials": Role.SUBSCRIBER,
|
|
82
|
+
"auth:manage_connections": Role.ADMIN,
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
export type Permission = keyof typeof Permissions;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a user has a specific permission
|
|
89
|
+
*/
|
|
90
|
+
export function hasPermission(
|
|
91
|
+
user: { role: RoleLevel } | null | undefined,
|
|
92
|
+
permission: Permission,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (!user) return false;
|
|
95
|
+
return user.role >= Permissions[permission];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Require a permission, throwing if not met
|
|
100
|
+
*/
|
|
101
|
+
export function requirePermission(
|
|
102
|
+
user: { role: RoleLevel } | null | undefined,
|
|
103
|
+
permission: Permission,
|
|
104
|
+
): asserts user is { role: RoleLevel } {
|
|
105
|
+
if (!user) {
|
|
106
|
+
throw new PermissionError("unauthorized", "Authentication required");
|
|
107
|
+
}
|
|
108
|
+
if (!hasPermission(user, permission)) {
|
|
109
|
+
throw new PermissionError("forbidden", `Missing permission: ${permission}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if user can perform action on a resource they own
|
|
115
|
+
*/
|
|
116
|
+
export function canActOnOwn(
|
|
117
|
+
user: { role: RoleLevel; id: string } | null | undefined,
|
|
118
|
+
ownerId: string,
|
|
119
|
+
ownPermission: Permission,
|
|
120
|
+
anyPermission: Permission,
|
|
121
|
+
): boolean {
|
|
122
|
+
if (!user) return false;
|
|
123
|
+
if (user.id === ownerId) {
|
|
124
|
+
return hasPermission(user, ownPermission);
|
|
125
|
+
}
|
|
126
|
+
return hasPermission(user, anyPermission);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Require permission on a resource, checking ownership
|
|
131
|
+
*/
|
|
132
|
+
export function requirePermissionOnResource(
|
|
133
|
+
user: { role: RoleLevel; id: string } | null | undefined,
|
|
134
|
+
ownerId: string,
|
|
135
|
+
ownPermission: Permission,
|
|
136
|
+
anyPermission: Permission,
|
|
137
|
+
): asserts user is { role: RoleLevel; id: string } {
|
|
138
|
+
if (!user) {
|
|
139
|
+
throw new PermissionError("unauthorized", "Authentication required");
|
|
140
|
+
}
|
|
141
|
+
if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
|
|
142
|
+
throw new PermissionError("forbidden", `Missing permission: ${anyPermission}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export class PermissionError extends Error {
|
|
147
|
+
constructor(
|
|
148
|
+
public code: "unauthorized" | "forbidden",
|
|
149
|
+
message: string,
|
|
150
|
+
) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = "PermissionError";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// API Token Scope ↔ Role mapping
|
|
158
|
+
//
|
|
159
|
+
// Maps each API token scope to the minimum RBAC role required to hold it.
|
|
160
|
+
// Used at token issuance time to clamp granted scopes to the user's role.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Minimum role required for each API token scope.
|
|
165
|
+
*
|
|
166
|
+
* This is the authoritative mapping between the two authorization systems
|
|
167
|
+
* (RBAC roles and API token scopes). When issuing a token, the granted
|
|
168
|
+
* scopes must be intersected with the scopes allowed by the user's role.
|
|
169
|
+
*/
|
|
170
|
+
const SCOPE_MIN_ROLE: Record<ApiTokenScope, RoleLevel> = {
|
|
171
|
+
"content:read": Role.SUBSCRIBER,
|
|
172
|
+
"content:write": Role.CONTRIBUTOR,
|
|
173
|
+
"media:read": Role.SUBSCRIBER,
|
|
174
|
+
"media:write": Role.CONTRIBUTOR,
|
|
175
|
+
"schema:read": Role.EDITOR,
|
|
176
|
+
"schema:write": Role.ADMIN,
|
|
177
|
+
admin: Role.ADMIN,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Return the maximum set of API token scopes a given role level may hold.
|
|
182
|
+
*
|
|
183
|
+
* Used at token issuance time (device flow, authorization code exchange)
|
|
184
|
+
* to enforce: effective_scopes = requested_scopes ∩ scopesForRole(role).
|
|
185
|
+
*/
|
|
186
|
+
export function scopesForRole(role: RoleLevel): ApiTokenScope[] {
|
|
187
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Object.entries loses tuple types; SCOPE_MIN_ROLE keys are ApiTokenScope by construction
|
|
188
|
+
const entries = Object.entries(SCOPE_MIN_ROLE) as [ApiTokenScope, RoleLevel][];
|
|
189
|
+
return entries.reduce<ApiTokenScope[]>((acc, [scope, minRole]) => {
|
|
190
|
+
if (role >= minRole) acc.push(scope);
|
|
191
|
+
return acc;
|
|
192
|
+
}, []);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Clamp a set of requested scopes to those permitted by a user's role.
|
|
197
|
+
*
|
|
198
|
+
* Returns the intersection of `requested` and the scopes the role allows.
|
|
199
|
+
* This is the central policy enforcement point: effective permissions =
|
|
200
|
+
* role permissions ∩ token scopes.
|
|
201
|
+
*/
|
|
202
|
+
export function clampScopes(requested: string[], role: RoleLevel): string[] {
|
|
203
|
+
const allowed = new Set<string>(scopesForRole(role));
|
|
204
|
+
return requested.filter((s) => allowed.has(s));
|
|
205
|
+
}
|