@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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +572 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/auth-config.d.ts +85 -0
  8. package/dist/client/auth-config.d.ts.map +1 -0
  9. package/dist/client/auth-config.js +81 -0
  10. package/dist/client/auth-config.js.map +1 -0
  11. package/dist/client/auth-helper.d.ts +81 -0
  12. package/dist/client/auth-helper.d.ts.map +1 -0
  13. package/dist/client/auth-helper.js +97 -0
  14. package/dist/client/auth-helper.js.map +1 -0
  15. package/dist/client/index.d.ts +189 -0
  16. package/dist/client/index.d.ts.map +1 -0
  17. package/dist/client/index.js +230 -0
  18. package/dist/client/index.js.map +1 -0
  19. package/dist/client/routes.d.ts +94 -0
  20. package/dist/client/routes.d.ts.map +1 -0
  21. package/dist/client/routes.js +113 -0
  22. package/dist/client/routes.js.map +1 -0
  23. package/dist/component/_generated/api.d.ts +44 -0
  24. package/dist/component/_generated/api.d.ts.map +1 -0
  25. package/dist/component/_generated/api.js +31 -0
  26. package/dist/component/_generated/api.js.map +1 -0
  27. package/dist/component/_generated/component.d.ts +123 -0
  28. package/dist/component/_generated/component.d.ts.map +1 -0
  29. package/dist/component/_generated/component.js +11 -0
  30. package/dist/component/_generated/component.js.map +1 -0
  31. package/dist/component/_generated/dataModel.d.ts +46 -0
  32. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  33. package/dist/component/_generated/dataModel.js +11 -0
  34. package/dist/component/_generated/dataModel.js.map +1 -0
  35. package/dist/component/_generated/server.d.ts +121 -0
  36. package/dist/component/_generated/server.d.ts.map +1 -0
  37. package/dist/component/_generated/server.js +78 -0
  38. package/dist/component/_generated/server.js.map +1 -0
  39. package/dist/component/clientManagement.d.ts +39 -0
  40. package/dist/component/clientManagement.d.ts.map +1 -0
  41. package/dist/component/clientManagement.js +169 -0
  42. package/dist/component/clientManagement.js.map +1 -0
  43. package/dist/component/constants.d.ts +31 -0
  44. package/dist/component/constants.d.ts.map +1 -0
  45. package/dist/component/constants.js +36 -0
  46. package/dist/component/constants.js.map +1 -0
  47. package/dist/component/convex.config.d.ts +3 -0
  48. package/dist/component/convex.config.d.ts.map +1 -0
  49. package/dist/component/convex.config.js +3 -0
  50. package/dist/component/convex.config.js.map +1 -0
  51. package/dist/component/handlers.d.ts +143 -0
  52. package/dist/component/handlers.d.ts.map +1 -0
  53. package/dist/component/handlers.js +624 -0
  54. package/dist/component/handlers.js.map +1 -0
  55. package/dist/component/mutations.d.ts +111 -0
  56. package/dist/component/mutations.d.ts.map +1 -0
  57. package/dist/component/mutations.js +459 -0
  58. package/dist/component/mutations.js.map +1 -0
  59. package/dist/component/queries.d.ts +127 -0
  60. package/dist/component/queries.d.ts.map +1 -0
  61. package/dist/component/queries.js +145 -0
  62. package/dist/component/queries.js.map +1 -0
  63. package/dist/component/schema.d.ts +116 -0
  64. package/dist/component/schema.d.ts.map +1 -0
  65. package/dist/component/schema.js +77 -0
  66. package/dist/component/schema.js.map +1 -0
  67. package/dist/component/token_security.d.ts +53 -0
  68. package/dist/component/token_security.d.ts.map +1 -0
  69. package/dist/component/token_security.js +91 -0
  70. package/dist/component/token_security.js.map +1 -0
  71. package/dist/lib/convex-types.d.ts +21 -0
  72. package/dist/lib/convex-types.d.ts.map +1 -0
  73. package/dist/lib/convex-types.js +2 -0
  74. package/dist/lib/convex-types.js.map +1 -0
  75. package/dist/lib/oauth.d.ts +123 -0
  76. package/dist/lib/oauth.d.ts.map +1 -0
  77. package/dist/lib/oauth.js +295 -0
  78. package/dist/lib/oauth.js.map +1 -0
  79. package/dist/react/index.d.ts +2 -0
  80. package/dist/react/index.d.ts.map +1 -0
  81. package/dist/react/index.js +6 -0
  82. package/dist/react/index.js.map +1 -0
  83. package/package.json +121 -0
  84. package/src/client/__tests__/auth-config.test.ts +244 -0
  85. package/src/client/__tests__/auth-helper.test.ts +273 -0
  86. package/src/client/__tests__/oauth-provider.test.ts +418 -0
  87. package/src/client/__tests__/routes.test.ts +428 -0
  88. package/src/client/_generated/_ignore.ts +1 -0
  89. package/src/client/auth-config.ts +157 -0
  90. package/src/client/auth-helper.ts +201 -0
  91. package/src/client/index.ts +326 -0
  92. package/src/client/routes.ts +251 -0
  93. package/src/component/__tests__/oauth.test.ts +3310 -0
  94. package/src/component/__tests__/rfc-compliance.test.ts +788 -0
  95. package/src/component/__tests__/token-security.test.ts +133 -0
  96. package/src/component/_generated/api.ts +60 -0
  97. package/src/component/_generated/component.ts +201 -0
  98. package/src/component/_generated/dataModel.ts +60 -0
  99. package/src/component/_generated/server.ts +156 -0
  100. package/src/component/clientManagement.ts +189 -0
  101. package/src/component/constants.ts +40 -0
  102. package/src/component/convex.config.ts +3 -0
  103. package/src/component/handlers.ts +964 -0
  104. package/src/component/mutations.ts +531 -0
  105. package/src/component/queries.ts +165 -0
  106. package/src/component/schema.ts +92 -0
  107. package/src/component/token_security.ts +102 -0
  108. package/src/lib/__tests__/oauth-helpers.test.ts +143 -0
  109. package/src/lib/__tests__/oauth-jwt.test.ts +405 -0
  110. package/src/lib/convex-types.ts +37 -0
  111. package/src/lib/oauth.ts +412 -0
  112. package/src/react/index.ts +7 -0
  113. 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
+ });