@codefox-inc/oauth-provider 0.2.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 +201 -0
- package/README.md +572 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/auth-config.d.ts +85 -0
- package/dist/client/auth-config.d.ts.map +1 -0
- package/dist/client/auth-config.js +81 -0
- package/dist/client/auth-config.js.map +1 -0
- package/dist/client/auth-helper.d.ts +81 -0
- package/dist/client/auth-helper.d.ts.map +1 -0
- package/dist/client/auth-helper.js +97 -0
- package/dist/client/auth-helper.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +230 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/routes.d.ts +94 -0
- package/dist/client/routes.d.ts.map +1 -0
- package/dist/client/routes.js +113 -0
- package/dist/client/routes.js.map +1 -0
- package/dist/component/_generated/api.d.ts +44 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +123 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/clientManagement.d.ts +39 -0
- package/dist/component/clientManagement.d.ts.map +1 -0
- package/dist/component/clientManagement.js +169 -0
- package/dist/component/clientManagement.js.map +1 -0
- package/dist/component/constants.d.ts +31 -0
- package/dist/component/constants.d.ts.map +1 -0
- package/dist/component/constants.js +36 -0
- package/dist/component/constants.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/handlers.d.ts +143 -0
- package/dist/component/handlers.d.ts.map +1 -0
- package/dist/component/handlers.js +624 -0
- package/dist/component/handlers.js.map +1 -0
- package/dist/component/mutations.d.ts +111 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +459 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +127 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +145 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +116 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +77 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/token_security.d.ts +53 -0
- package/dist/component/token_security.d.ts.map +1 -0
- package/dist/component/token_security.js +91 -0
- package/dist/component/token_security.js.map +1 -0
- package/dist/lib/convex-types.d.ts +21 -0
- package/dist/lib/convex-types.d.ts.map +1 -0
- package/dist/lib/convex-types.js +2 -0
- package/dist/lib/convex-types.js.map +1 -0
- package/dist/lib/oauth.d.ts +123 -0
- package/dist/lib/oauth.d.ts.map +1 -0
- package/dist/lib/oauth.js +295 -0
- package/dist/lib/oauth.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +121 -0
- package/src/client/__tests__/auth-config.test.ts +244 -0
- package/src/client/__tests__/auth-helper.test.ts +273 -0
- package/src/client/__tests__/oauth-provider.test.ts +418 -0
- package/src/client/__tests__/routes.test.ts +428 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/auth-config.ts +157 -0
- package/src/client/auth-helper.ts +201 -0
- package/src/client/index.ts +326 -0
- package/src/client/routes.ts +251 -0
- package/src/component/__tests__/oauth.test.ts +3310 -0
- package/src/component/__tests__/rfc-compliance.test.ts +788 -0
- package/src/component/__tests__/token-security.test.ts +133 -0
- package/src/component/_generated/api.ts +60 -0
- package/src/component/_generated/component.ts +201 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/clientManagement.ts +189 -0
- package/src/component/constants.ts +40 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/handlers.ts +964 -0
- package/src/component/mutations.ts +531 -0
- package/src/component/queries.ts +165 -0
- package/src/component/schema.ts +92 -0
- package/src/component/token_security.ts +102 -0
- package/src/lib/__tests__/oauth-helpers.test.ts +143 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +405 -0
- package/src/lib/convex-types.ts +37 -0
- package/src/lib/oauth.ts +412 -0
- package/src/react/index.ts +7 -0
- package/src/test.ts +21 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
/**
|
|
6
|
+
* OAuth Clients
|
|
7
|
+
* Registered applications that can request authorization
|
|
8
|
+
*/
|
|
9
|
+
oauthClients: defineTable({
|
|
10
|
+
name: v.string(),
|
|
11
|
+
description: v.optional(v.string()),
|
|
12
|
+
logoUrl: v.optional(v.string()),
|
|
13
|
+
website: v.optional(v.string()),
|
|
14
|
+
tosUrl: v.optional(v.string()),
|
|
15
|
+
policyUrl: v.optional(v.string()),
|
|
16
|
+
|
|
17
|
+
// Client Credentials
|
|
18
|
+
clientId: v.string(), // Public ID (UUID v4)
|
|
19
|
+
clientSecret: v.optional(v.string()), // Hashed Secret (for confidential clients)
|
|
20
|
+
type: v.union(v.literal("confidential"), v.literal("public")),
|
|
21
|
+
|
|
22
|
+
redirectUris: v.array(v.string()), // Must be exact match
|
|
23
|
+
allowedScopes: v.array(v.string()), // e.g. ["openid", "profile", "email"]
|
|
24
|
+
|
|
25
|
+
isInternal: v.optional(v.boolean()), // Internal tool flag
|
|
26
|
+
|
|
27
|
+
createdAt: v.number(),
|
|
28
|
+
}).index("by_client_id", ["clientId"]),
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* OAuth Authorization Codes
|
|
32
|
+
* Short-lived codes for authorization code flow
|
|
33
|
+
*/
|
|
34
|
+
oauthCodes: defineTable({
|
|
35
|
+
code: v.string(),
|
|
36
|
+
clientId: v.string(),
|
|
37
|
+
userId: v.string(), // Convex users table Id (string, not v.id since component doesn't know about users table)
|
|
38
|
+
scopes: v.array(v.string()),
|
|
39
|
+
redirectUri: v.string(),
|
|
40
|
+
|
|
41
|
+
// PKCE
|
|
42
|
+
codeChallenge: v.string(),
|
|
43
|
+
codeChallengeMethod: v.string(), // "S256" or "plain"
|
|
44
|
+
nonce: v.optional(v.string()), // OIDC Nonce
|
|
45
|
+
|
|
46
|
+
expiresAt: v.number(), // Usually 10 minutes
|
|
47
|
+
usedAt: v.optional(v.number()), // RFC Line 1136: Track code usage for replay detection
|
|
48
|
+
}).index("by_code", ["code"]),
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* OAuth Tokens
|
|
52
|
+
* Access and Refresh tokens
|
|
53
|
+
*/
|
|
54
|
+
oauthTokens: defineTable({
|
|
55
|
+
accessToken: v.string(),
|
|
56
|
+
refreshToken: v.optional(v.string()),
|
|
57
|
+
|
|
58
|
+
clientId: v.string(),
|
|
59
|
+
userId: v.string(), // Convex users table Id (string)
|
|
60
|
+
scopes: v.array(v.string()),
|
|
61
|
+
|
|
62
|
+
expiresAt: v.number(), // Access Token Expiry
|
|
63
|
+
refreshTokenExpiresAt: v.optional(v.number()), // Refresh Token Expiry
|
|
64
|
+
|
|
65
|
+
// RFC Line 1136: Track which authorization code issued this token for replay detection
|
|
66
|
+
authorizationCode: v.optional(v.string()), // Hashed authorization code
|
|
67
|
+
})
|
|
68
|
+
.index("by_access_token", ["accessToken"])
|
|
69
|
+
.index("by_refresh_token", ["refreshToken"])
|
|
70
|
+
.index("by_user", ["userId"])
|
|
71
|
+
.index("by_authorization_code", ["authorizationCode"]),
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* OAuth Authorizations
|
|
75
|
+
* User consent records - persists beyond token expiry
|
|
76
|
+
*/
|
|
77
|
+
oauthAuthorizations: defineTable({
|
|
78
|
+
userId: v.string(), // Convex users table Id (string)
|
|
79
|
+
clientId: v.string(),
|
|
80
|
+
|
|
81
|
+
// Authorized scopes
|
|
82
|
+
scopes: v.array(v.string()),
|
|
83
|
+
|
|
84
|
+
// When the user first authorized this client
|
|
85
|
+
authorizedAt: v.number(),
|
|
86
|
+
|
|
87
|
+
// Last time a token was issued for this authorization
|
|
88
|
+
lastUsedAt: v.optional(v.number()),
|
|
89
|
+
})
|
|
90
|
+
.index("by_user", ["userId"])
|
|
91
|
+
.index("by_user_client", ["userId", "clientId"]),
|
|
92
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Security Utilities for OAuth Provider (Web Crypto API)
|
|
3
|
+
*
|
|
4
|
+
* Provides secure token hashing for database storage.
|
|
5
|
+
* Tokens are hashed using SHA-256 before storage - the original token
|
|
6
|
+
* value is never stored, only returned to the client during issuance.
|
|
7
|
+
*
|
|
8
|
+
* This is more secure than encryption because:
|
|
9
|
+
* 1. Even with DB access + encryption key, tokens can't be recovered
|
|
10
|
+
* 2. Token validation only requires hash comparison
|
|
11
|
+
* 3. Clients already have the original token
|
|
12
|
+
*
|
|
13
|
+
* @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert string to Uint8Array
|
|
18
|
+
*/
|
|
19
|
+
function stringToBytes(str: string): Uint8Array {
|
|
20
|
+
return new TextEncoder().encode(str);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a SHA-256 hash of a token for secure storage.
|
|
25
|
+
*
|
|
26
|
+
* @param token - The plaintext token to hash
|
|
27
|
+
* @returns Hex-encoded hash suitable for database storage and indexing
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const accessToken = generateCode(64);
|
|
32
|
+
* const accessTokenHash = await hashToken(accessToken);
|
|
33
|
+
* // Store accessTokenHash in DB, return accessToken to client
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export async function hashToken(token: string): Promise<string> {
|
|
37
|
+
const tokenBytes = stringToBytes(token);
|
|
38
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
39
|
+
"SHA-256",
|
|
40
|
+
tokenBytes.buffer as ArrayBuffer
|
|
41
|
+
);
|
|
42
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
43
|
+
return Array.from(hashArray)
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
45
|
+
.join("");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Timing-safe string comparison to prevent timing attacks.
|
|
50
|
+
* Compares two strings in constant time regardless of where they differ.
|
|
51
|
+
*/
|
|
52
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
53
|
+
if (a.length !== b.length) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
let result = 0;
|
|
57
|
+
for (let i = 0; i < a.length; i++) {
|
|
58
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
59
|
+
}
|
|
60
|
+
return result === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Verifies a token against its stored hash.
|
|
65
|
+
*
|
|
66
|
+
* @param token - The plaintext token to verify
|
|
67
|
+
* @param hash - The stored hash to compare against
|
|
68
|
+
* @returns true if the token matches the hash
|
|
69
|
+
*/
|
|
70
|
+
export async function verifyToken(
|
|
71
|
+
token: string,
|
|
72
|
+
hash: string
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const tokenHash = await hashToken(token);
|
|
75
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
76
|
+
return timingSafeEqual(tokenHash, hash);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a value looks like a SHA-256 hash (64 hex characters).
|
|
81
|
+
* Used for backward compatibility during migration.
|
|
82
|
+
*
|
|
83
|
+
* @param value - The value to check
|
|
84
|
+
* @returns true if the value appears to be a hash
|
|
85
|
+
*/
|
|
86
|
+
export function isHashedToken(value: string): boolean {
|
|
87
|
+
return /^[a-f0-9]{64}$/.test(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Hash a token if it's not already hashed.
|
|
92
|
+
* Used for backward compatibility during migration.
|
|
93
|
+
*
|
|
94
|
+
* @param token - Token that may or may not be hashed
|
|
95
|
+
* @returns The hash (either computed or passed through if already hashed)
|
|
96
|
+
*/
|
|
97
|
+
export async function ensureHashed(token: string): Promise<string> {
|
|
98
|
+
if (isHashedToken(token)) {
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
return hashToken(token);
|
|
102
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isOAuthToken,
|
|
4
|
+
getOAuthClientId,
|
|
5
|
+
DEFAULT_OAUTH_ISSUER_PATTERN,
|
|
6
|
+
generateCode,
|
|
7
|
+
generateClientSecret
|
|
8
|
+
} from "../oauth";
|
|
9
|
+
|
|
10
|
+
describe("OAuth Token Helpers", () => {
|
|
11
|
+
describe("DEFAULT_OAUTH_ISSUER_PATTERN", () => {
|
|
12
|
+
it("should be /oauth", () => {
|
|
13
|
+
expect(DEFAULT_OAUTH_ISSUER_PATTERN).toBe("/oauth");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("isOAuthToken", () => {
|
|
18
|
+
it("should return true for valid OAuth token identity", () => {
|
|
19
|
+
const identity = {
|
|
20
|
+
issuer: "https://example.com/oauth",
|
|
21
|
+
subject: "user123",
|
|
22
|
+
};
|
|
23
|
+
expect(isOAuthToken(identity)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should return true when issuer ends with /oauth", () => {
|
|
27
|
+
const identity = {
|
|
28
|
+
issuer: "https://my-app.convex.site/oauth",
|
|
29
|
+
subject: "jh7abcdefghijk123456789",
|
|
30
|
+
};
|
|
31
|
+
expect(isOAuthToken(identity)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should return false for Convex Auth identity", () => {
|
|
35
|
+
const identity = {
|
|
36
|
+
issuer: "https://convex.dev",
|
|
37
|
+
subject: "user123",
|
|
38
|
+
};
|
|
39
|
+
expect(isOAuthToken(identity)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should return false when issuer is missing", () => {
|
|
43
|
+
const identity = { subject: "user123" };
|
|
44
|
+
expect(isOAuthToken(identity)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should return false when subject is missing", () => {
|
|
48
|
+
const identity = { issuer: "https://example.com/oauth" };
|
|
49
|
+
expect(isOAuthToken(identity)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should return false for null identity", () => {
|
|
53
|
+
expect(isOAuthToken(null)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should return false for undefined identity", () => {
|
|
57
|
+
expect(isOAuthToken(undefined)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should accept custom issuer pattern", () => {
|
|
61
|
+
const identity = {
|
|
62
|
+
issuer: "https://example.com/custom-oauth-path",
|
|
63
|
+
subject: "user123",
|
|
64
|
+
};
|
|
65
|
+
expect(isOAuthToken(identity, "/custom-oauth-path")).toBe(true);
|
|
66
|
+
expect(isOAuthToken(identity, "/oauth")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("getOAuthClientId", () => {
|
|
71
|
+
it("should return client ID when present", () => {
|
|
72
|
+
const identity = { cid: "client123" };
|
|
73
|
+
expect(getOAuthClientId(identity)).toBe("client123");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return undefined when cid is missing", () => {
|
|
77
|
+
const identity = {};
|
|
78
|
+
expect(getOAuthClientId(identity)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return undefined for null identity", () => {
|
|
82
|
+
expect(getOAuthClientId(null)).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should return undefined for undefined identity", () => {
|
|
86
|
+
expect(getOAuthClientId(undefined)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("generateCode", () => {
|
|
91
|
+
it("should generate a code with default length", () => {
|
|
92
|
+
const code = generateCode();
|
|
93
|
+
expect(code).toBeDefined();
|
|
94
|
+
expect(typeof code).toBe("string");
|
|
95
|
+
expect(code.length).toBeGreaterThan(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should generate a code with specified length", () => {
|
|
99
|
+
const code = generateCode(32);
|
|
100
|
+
expect(code).toBeDefined();
|
|
101
|
+
expect(code.length).toBeGreaterThanOrEqual(32);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should generate different codes on each call", () => {
|
|
105
|
+
const code1 = generateCode();
|
|
106
|
+
const code2 = generateCode();
|
|
107
|
+
expect(code1).not.toBe(code2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should generate URL-safe codes", () => {
|
|
111
|
+
const code = generateCode(100);
|
|
112
|
+
// OAuth unreserved characters: A-Za-z0-9-._~
|
|
113
|
+
expect(code).toMatch(/^[A-Za-z0-9_~.-]+$/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("generateClientSecret", () => {
|
|
118
|
+
it("should generate a secret with default length", () => {
|
|
119
|
+
const secret = generateClientSecret();
|
|
120
|
+
expect(secret).toBeDefined();
|
|
121
|
+
expect(typeof secret).toBe("string");
|
|
122
|
+
expect(secret.length).toBeGreaterThan(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should generate a secret with specified length", () => {
|
|
126
|
+
const secret = generateClientSecret(64);
|
|
127
|
+
expect(secret).toBeDefined();
|
|
128
|
+
expect(secret.length).toBeGreaterThanOrEqual(64);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should generate different secrets on each call", () => {
|
|
132
|
+
const secret1 = generateClientSecret();
|
|
133
|
+
const secret2 = generateClientSecret();
|
|
134
|
+
expect(secret1).not.toBe(secret2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should generate hex secrets", () => {
|
|
138
|
+
const secret = generateClientSecret(100);
|
|
139
|
+
expect(secret).toMatch(/^[A-Fa-f0-9]+$/);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
});
|