@codefox-inc/oauth-provider 0.3.2 → 0.4.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/README.md +40 -14
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/clientManagement.d.ts +1 -0
- package/dist/component/clientManagement.d.ts.map +1 -1
- package/dist/component/clientManagement.js +24 -0
- package/dist/component/clientManagement.js.map +1 -1
- package/dist/component/handlers.d.ts +16 -0
- package/dist/component/handlers.d.ts.map +1 -1
- package/dist/component/handlers.js +275 -28
- package/dist/component/handlers.js.map +1 -1
- package/dist/component/mutations.d.ts +9 -0
- package/dist/component/mutations.d.ts.map +1 -1
- package/dist/component/mutations.js +112 -40
- package/dist/component/mutations.js.map +1 -1
- package/dist/component/queries.d.ts +8 -0
- package/dist/component/queries.d.ts.map +1 -1
- package/dist/component/schema.d.ts +18 -4
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +7 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/lib/oauth.d.ts.map +1 -1
- package/dist/lib/oauth.js +5 -2
- package/dist/lib/oauth.js.map +1 -1
- package/package.json +39 -39
- package/src/client/__tests__/oauth-provider.test.ts +39 -0
- package/src/client/index.ts +4 -0
- package/src/component/__tests__/handlers-protocol.test.ts +880 -0
- package/src/component/__tests__/mutations-protocol.test.ts +448 -0
- package/src/component/__tests__/oauth.test.ts +32 -28
- package/src/component/__tests__/rfc-compliance.test.ts +79 -11
- package/src/component/_generated/component.ts +17 -1
- package/src/component/clientManagement.ts +31 -0
- package/src/component/handlers.ts +355 -31
- package/src/component/mutations.ts +133 -40
- package/src/component/schema.ts +11 -0
- package/src/lib/__tests__/oauth-jwt.test.ts +68 -0
- package/src/lib/oauth.ts +8 -4
|
@@ -9,6 +9,9 @@ import type { OAuthComponentAPI } from "../handlers";
|
|
|
9
9
|
import type { OAuthConfig } from "../../lib/oauth";
|
|
10
10
|
|
|
11
11
|
const modules = import.meta.glob("../**/*.ts");
|
|
12
|
+
const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
|
13
|
+
const validCodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
|
14
|
+
const wrongCodeVerifier = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
12
15
|
|
|
13
16
|
describe("OAuth 2.1 Flow", () => {
|
|
14
17
|
let t: ReturnType<typeof convexTest>;
|
|
@@ -321,7 +324,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
321
324
|
|
|
322
325
|
test("Token Handler: authorization_code grant issues tokens with ID token and refresh token", async () => {
|
|
323
326
|
// Generate valid RSA key pair for JWT signing
|
|
324
|
-
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
|
327
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
325
328
|
const privateKeyPem = await exportPKCS8(privateKey);
|
|
326
329
|
const jwk = await exportJWK(publicKey);
|
|
327
330
|
const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
|
|
@@ -788,7 +791,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
788
791
|
},
|
|
789
792
|
};
|
|
790
793
|
|
|
791
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&code_challenge=
|
|
794
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
792
795
|
method: "GET",
|
|
793
796
|
});
|
|
794
797
|
|
|
@@ -844,7 +847,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
844
847
|
},
|
|
845
848
|
};
|
|
846
849
|
|
|
847
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20admin&state=abc&consent=approve&code_challenge=
|
|
850
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20admin&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
848
851
|
method: "GET",
|
|
849
852
|
headers: {
|
|
850
853
|
"Referer": "https://example.com/consent",
|
|
@@ -903,7 +906,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
903
906
|
},
|
|
904
907
|
};
|
|
905
908
|
|
|
906
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&state=state-123&consent=approve&code_challenge=
|
|
909
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid%20profile&state=state-123&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
907
910
|
method: "GET",
|
|
908
911
|
headers: {
|
|
909
912
|
"Referer": "https://example.com/consent",
|
|
@@ -962,7 +965,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
962
965
|
},
|
|
963
966
|
};
|
|
964
967
|
|
|
965
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=
|
|
968
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
966
969
|
method: "GET",
|
|
967
970
|
headers: {
|
|
968
971
|
"Referer": "https://example.com/consent",
|
|
@@ -1181,7 +1184,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1181
1184
|
},
|
|
1182
1185
|
};
|
|
1183
1186
|
|
|
1184
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=
|
|
1187
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=plain", {
|
|
1185
1188
|
method: "GET",
|
|
1186
1189
|
headers: {
|
|
1187
1190
|
"Referer": "https://example.com/consent",
|
|
@@ -1240,7 +1243,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1240
1243
|
},
|
|
1241
1244
|
};
|
|
1242
1245
|
|
|
1243
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=
|
|
1246
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&state=abc&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
1244
1247
|
method: "GET",
|
|
1245
1248
|
headers: {
|
|
1246
1249
|
"Referer": "https://client.example.com/start",
|
|
@@ -1450,7 +1453,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1450
1453
|
method: "POST",
|
|
1451
1454
|
body: JSON.stringify({
|
|
1452
1455
|
redirect_uris: ["https://cb"],
|
|
1453
|
-
token_endpoint_auth_method: "
|
|
1456
|
+
token_endpoint_auth_method: "private_key_jwt",
|
|
1454
1457
|
}),
|
|
1455
1458
|
headers: { "Content-Type": "application/json" },
|
|
1456
1459
|
});
|
|
@@ -1561,7 +1564,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1561
1564
|
const response = await registerHandler({} as any, request, config, apiStub);
|
|
1562
1565
|
expect(response.status).toBe(400);
|
|
1563
1566
|
const body = await response.json();
|
|
1564
|
-
expect(body.error).toBe("
|
|
1567
|
+
expect(body.error).toBe("invalid_redirect_uri");
|
|
1565
1568
|
expect(body.error_description).toContain("Invalid redirect_uri");
|
|
1566
1569
|
});
|
|
1567
1570
|
|
|
@@ -1877,7 +1880,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1877
1880
|
|
|
1878
1881
|
test("Token Handler: handles rotateRefreshToken error with invalid_grant", async () => {
|
|
1879
1882
|
// Generate valid RSA key pair for JWT signing
|
|
1880
|
-
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
|
1883
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
1881
1884
|
const privateKeyPem = await exportPKCS8(privateKey);
|
|
1882
1885
|
const jwk = await exportJWK(publicKey);
|
|
1883
1886
|
const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
|
|
@@ -1949,7 +1952,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
1949
1952
|
|
|
1950
1953
|
test("Token Handler: handles rotateRefreshToken error (non-invalid_grant)", async () => {
|
|
1951
1954
|
// Generate valid RSA key pair for JWT signing
|
|
1952
|
-
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
|
1955
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
1953
1956
|
const privateKeyPem = await exportPKCS8(privateKey);
|
|
1954
1957
|
const jwk = await exportJWK(publicKey);
|
|
1955
1958
|
const jwks = JSON.stringify({ keys: [{ ...jwk, kid: "test-key", use: "sig", alg: "RS256" }] });
|
|
@@ -2061,7 +2064,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2061
2064
|
},
|
|
2062
2065
|
};
|
|
2063
2066
|
|
|
2064
|
-
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&consent=approve&code_challenge=
|
|
2067
|
+
const request = new Request("https://example.com/oauth/authorize?response_type=code&client_id=client&redirect_uri=https%3A%2F%2Fcb&scope=openid&consent=approve&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256", {
|
|
2065
2068
|
method: "GET",
|
|
2066
2069
|
headers: {
|
|
2067
2070
|
"Referer": "https://example.com/consent",
|
|
@@ -2093,7 +2096,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2093
2096
|
userId,
|
|
2094
2097
|
redirectUri: "https://cb",
|
|
2095
2098
|
scopes: [],
|
|
2096
|
-
codeChallenge:
|
|
2099
|
+
codeChallenge: validCodeChallenge,
|
|
2097
2100
|
codeChallengeMethod: "S256" // Changed from "plain" to "S256"
|
|
2098
2101
|
});
|
|
2099
2102
|
|
|
@@ -2563,7 +2566,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2563
2566
|
scopes: string[],
|
|
2564
2567
|
options: { clientId?: string } = {}
|
|
2565
2568
|
) {
|
|
2566
|
-
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
|
2569
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true });
|
|
2567
2570
|
const jwk = await exportJWK(publicKey);
|
|
2568
2571
|
const jwks = JSON.stringify({
|
|
2569
2572
|
keys: [{
|
|
@@ -2581,14 +2584,17 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2581
2584
|
};
|
|
2582
2585
|
const payload: Record<string, unknown> = {
|
|
2583
2586
|
scp: scopes.join(" "),
|
|
2587
|
+
scope: scopes.join(" "),
|
|
2588
|
+
jti: crypto.randomUUID(),
|
|
2584
2589
|
};
|
|
2585
2590
|
if (options.clientId) {
|
|
2586
2591
|
payload.cid = options.clientId;
|
|
2592
|
+
payload.client_id = options.clientId;
|
|
2587
2593
|
}
|
|
2588
2594
|
const token = await new SignJWT({
|
|
2589
2595
|
...payload,
|
|
2590
2596
|
})
|
|
2591
|
-
.setProtectedHeader({ alg: "RS256", kid: "default-key" })
|
|
2597
|
+
.setProtectedHeader({ alg: "RS256", typ: "at+jwt", kid: "default-key" })
|
|
2592
2598
|
.setIssuedAt()
|
|
2593
2599
|
.setSubject("user-1")
|
|
2594
2600
|
.setAudience("convex")
|
|
@@ -2765,8 +2771,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2765
2771
|
|
|
2766
2772
|
expect(response.status).toBe(401);
|
|
2767
2773
|
const wwwAuth = response.headers.get("WWW-Authenticate");
|
|
2768
|
-
expect(wwwAuth).
|
|
2769
|
-
expect(wwwAuth).toContain("Missing bearer token");
|
|
2774
|
+
expect(wwwAuth).toBe('Bearer realm="userinfo"');
|
|
2770
2775
|
});
|
|
2771
2776
|
|
|
2772
2777
|
test("UserInfo: returns 401 when Authorization header is malformed", async () => {
|
|
@@ -2781,8 +2786,7 @@ describe("OAuth 2.1 Flow", () => {
|
|
|
2781
2786
|
|
|
2782
2787
|
expect(response.status).toBe(401);
|
|
2783
2788
|
const wwwAuth = response.headers.get("WWW-Authenticate");
|
|
2784
|
-
expect(wwwAuth).
|
|
2785
|
-
expect(wwwAuth).toContain("Missing bearer token");
|
|
2789
|
+
expect(wwwAuth).toBe('Bearer realm="userinfo"');
|
|
2786
2790
|
});
|
|
2787
2791
|
|
|
2788
2792
|
test("UserInfo: returns 401 when token verification fails", async () => {
|
|
@@ -2866,7 +2870,7 @@ codeHash: "test-code-hash",
|
|
|
2866
2870
|
});
|
|
2867
2871
|
|
|
2868
2872
|
test("Token Handler: refresh_token grant succeeds with offline_access scope", async () => {
|
|
2869
|
-
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
|
2873
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
2870
2874
|
const privateKeyPEM = await exportPKCS8(privateKey);
|
|
2871
2875
|
const publicJWK = await exportJWK(publicKey);
|
|
2872
2876
|
|
|
@@ -2950,7 +2954,7 @@ codeHash: "test-code-hash",
|
|
|
2950
2954
|
userId,
|
|
2951
2955
|
redirectUri: "https://cb",
|
|
2952
2956
|
scopes: ["openid"],
|
|
2953
|
-
codeChallenge:
|
|
2957
|
+
codeChallenge: validCodeChallenge,
|
|
2954
2958
|
codeChallengeMethod: "S256"
|
|
2955
2959
|
});
|
|
2956
2960
|
|
|
@@ -2959,7 +2963,7 @@ codeHash: "test-code-hash",
|
|
|
2959
2963
|
code,
|
|
2960
2964
|
clientId: client.clientId,
|
|
2961
2965
|
redirectUri: "https://wrong-redirect",
|
|
2962
|
-
codeVerifier:
|
|
2966
|
+
codeVerifier: validCodeVerifier,
|
|
2963
2967
|
})
|
|
2964
2968
|
).rejects.toThrow("redirect_uri_mismatch");
|
|
2965
2969
|
});
|
|
@@ -2978,7 +2982,7 @@ codeHash: "test-code-hash",
|
|
|
2978
2982
|
userId,
|
|
2979
2983
|
redirectUri: "https://cb",
|
|
2980
2984
|
scopes: ["openid"],
|
|
2981
|
-
codeChallenge:
|
|
2985
|
+
codeChallenge: validCodeChallenge,
|
|
2982
2986
|
codeChallengeMethod: "S256"
|
|
2983
2987
|
});
|
|
2984
2988
|
|
|
@@ -2987,7 +2991,7 @@ codeHash: "test-code-hash",
|
|
|
2987
2991
|
code,
|
|
2988
2992
|
clientId: client.clientId,
|
|
2989
2993
|
redirectUri: "https://cb",
|
|
2990
|
-
codeVerifier:
|
|
2994
|
+
codeVerifier: wrongCodeVerifier,
|
|
2991
2995
|
})
|
|
2992
2996
|
).rejects.toThrow("invalid_code_verifier");
|
|
2993
2997
|
});
|
|
@@ -3044,7 +3048,7 @@ codeHash: "test-code-hash",
|
|
|
3044
3048
|
userId,
|
|
3045
3049
|
redirectUri: "https://cb",
|
|
3046
3050
|
scopes: ["openid"],
|
|
3047
|
-
codeChallenge:
|
|
3051
|
+
codeChallenge: validCodeChallenge,
|
|
3048
3052
|
codeChallengeMethod: "S256",
|
|
3049
3053
|
expiresAt: Date.now() - 1000, // Expired
|
|
3050
3054
|
});
|
|
@@ -3078,7 +3082,7 @@ codeHash: "test-code-hash",
|
|
|
3078
3082
|
userId,
|
|
3079
3083
|
redirectUri: "https://cb",
|
|
3080
3084
|
scopes: ["openid"],
|
|
3081
|
-
codeChallenge:
|
|
3085
|
+
codeChallenge: validCodeChallenge,
|
|
3082
3086
|
codeChallengeMethod: "MD5", // Invalid method
|
|
3083
3087
|
expiresAt: Date.now() + 600000,
|
|
3084
3088
|
});
|
|
@@ -3089,7 +3093,7 @@ codeHash: "test-code-hash",
|
|
|
3089
3093
|
code,
|
|
3090
3094
|
clientId: client.clientId,
|
|
3091
3095
|
redirectUri: "https://cb",
|
|
3092
|
-
codeVerifier:
|
|
3096
|
+
codeVerifier: validCodeVerifier,
|
|
3093
3097
|
})
|
|
3094
3098
|
).rejects.toThrow("unsupported_code_challenge_method");
|
|
3095
3099
|
});
|
|
@@ -3268,7 +3272,7 @@ codeHash: "test-code-hash",
|
|
|
3268
3272
|
userId,
|
|
3269
3273
|
redirectUri: "https://cb",
|
|
3270
3274
|
scopes: ["openid"],
|
|
3271
|
-
codeChallenge:
|
|
3275
|
+
codeChallenge: validCodeChallenge,
|
|
3272
3276
|
codeChallengeMethod: "S256"
|
|
3273
3277
|
});
|
|
3274
3278
|
|
|
@@ -13,6 +13,8 @@ import schema from "../schema";
|
|
|
13
13
|
|
|
14
14
|
const modules = import.meta.glob("../**/*.ts");
|
|
15
15
|
|
|
16
|
+
const validCodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
|
17
|
+
|
|
16
18
|
describe("OAuth 2.1 RFC Compliance", () => {
|
|
17
19
|
describe("Section 4.1.1 - PKCE Requirements", () => {
|
|
18
20
|
test("MUST support code_challenge and code_verifier parameters", async () => {
|
|
@@ -104,7 +106,7 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
104
106
|
clientId: client.clientId,
|
|
105
107
|
scopes: ["openid"],
|
|
106
108
|
redirectUri: "https://example.com/callback",
|
|
107
|
-
codeChallenge:
|
|
109
|
+
codeChallenge: validCodeChallenge,
|
|
108
110
|
codeChallengeMethod: "plain",
|
|
109
111
|
})
|
|
110
112
|
).rejects.toThrow(/plain.*not.*support/i);
|
|
@@ -351,6 +353,60 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
351
353
|
});
|
|
352
354
|
expect(authCode2).toBeDefined();
|
|
353
355
|
});
|
|
356
|
+
|
|
357
|
+
test("MUST only allow port variance for loopback redirect URIs", async () => {
|
|
358
|
+
const t = convexTest(schema, modules);
|
|
359
|
+
|
|
360
|
+
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
361
|
+
name: "Native App Strict Loopback",
|
|
362
|
+
type: "public",
|
|
363
|
+
redirectUris: ["http://127.0.0.1/callback?flow=oauth"],
|
|
364
|
+
scopes: ["openid"],
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const authCode = await t.mutation(api.mutations.issueAuthorizationCode, {
|
|
368
|
+
userId: "user123",
|
|
369
|
+
clientId: client.clientId,
|
|
370
|
+
scopes: ["openid"],
|
|
371
|
+
redirectUri: "http://127.0.0.1:8080/callback?flow=oauth",
|
|
372
|
+
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
373
|
+
codeChallengeMethod: "S256",
|
|
374
|
+
});
|
|
375
|
+
expect(authCode).toBeDefined();
|
|
376
|
+
|
|
377
|
+
await expect(
|
|
378
|
+
t.mutation(api.mutations.issueAuthorizationCode, {
|
|
379
|
+
userId: "user123",
|
|
380
|
+
clientId: client.clientId,
|
|
381
|
+
scopes: ["openid"],
|
|
382
|
+
redirectUri: "https://127.0.0.1:8080/callback?flow=oauth",
|
|
383
|
+
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
384
|
+
codeChallengeMethod: "S256",
|
|
385
|
+
})
|
|
386
|
+
).rejects.toThrow(/redirect/i);
|
|
387
|
+
|
|
388
|
+
await expect(
|
|
389
|
+
t.mutation(api.mutations.issueAuthorizationCode, {
|
|
390
|
+
userId: "user123",
|
|
391
|
+
clientId: client.clientId,
|
|
392
|
+
scopes: ["openid"],
|
|
393
|
+
redirectUri: "http://127.0.0.1:8080/different?flow=oauth",
|
|
394
|
+
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
395
|
+
codeChallengeMethod: "S256",
|
|
396
|
+
})
|
|
397
|
+
).rejects.toThrow(/redirect/i);
|
|
398
|
+
|
|
399
|
+
await expect(
|
|
400
|
+
t.mutation(api.mutations.issueAuthorizationCode, {
|
|
401
|
+
userId: "user123",
|
|
402
|
+
clientId: client.clientId,
|
|
403
|
+
scopes: ["openid"],
|
|
404
|
+
redirectUri: "http://127.0.0.1:8080/callback?flow=other",
|
|
405
|
+
codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
406
|
+
codeChallengeMethod: "S256",
|
|
407
|
+
})
|
|
408
|
+
).rejects.toThrow(/redirect/i);
|
|
409
|
+
});
|
|
354
410
|
});
|
|
355
411
|
|
|
356
412
|
describe("Section 3.2.3 - Token Response", () => {
|
|
@@ -484,7 +540,7 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
484
540
|
expect(true).toBe(true); // Placeholder - verify no password grant implementation
|
|
485
541
|
});
|
|
486
542
|
|
|
487
|
-
test("
|
|
543
|
+
test("OAuth 2.1: redirect_uri is optional at token endpoint and validated only when supplied", async () => {
|
|
488
544
|
const t = convexTest(schema, modules);
|
|
489
545
|
|
|
490
546
|
const client = await t.mutation(api.clientManagement.registerClient, {
|
|
@@ -503,15 +559,14 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
503
559
|
codeChallengeMethod: "S256",
|
|
504
560
|
});
|
|
505
561
|
|
|
506
|
-
// redirect_uri
|
|
507
|
-
await
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
).rejects.toThrow("redirect_uri_required");
|
|
562
|
+
// redirect_uri 省略時も許可
|
|
563
|
+
const omittedRedirectData = await t.mutation(api.mutations.consumeAuthCode, {
|
|
564
|
+
code: authCode,
|
|
565
|
+
clientId: client.clientId,
|
|
566
|
+
codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(omittedRedirectData.userId).toBeDefined();
|
|
515
570
|
|
|
516
571
|
// redirect_uri付きなら成功
|
|
517
572
|
const authCode2 = await t.mutation(api.mutations.issueAuthorizationCode, {
|
|
@@ -740,6 +795,19 @@ describe("OAuth 2.1 RFC Compliance", () => {
|
|
|
740
795
|
});
|
|
741
796
|
|
|
742
797
|
expect(tokensAfter.length).toBe(0); // All tokens should be deleted
|
|
798
|
+
|
|
799
|
+
await expect(
|
|
800
|
+
t.mutation(api.mutations.saveTokens, {
|
|
801
|
+
accessToken: "replayed-access-token",
|
|
802
|
+
refreshToken: "replayed-refresh-token",
|
|
803
|
+
clientId: client.clientId,
|
|
804
|
+
userId: "user123",
|
|
805
|
+
scopes: ["openid", "profile"],
|
|
806
|
+
expiresAt: Date.now() + 3600000,
|
|
807
|
+
refreshTokenExpiresAt: Date.now() + 2592000000,
|
|
808
|
+
authorizationCode: codeData.codeHash,
|
|
809
|
+
})
|
|
810
|
+
).rejects.toThrow("authorization_code_reuse_detected");
|
|
743
811
|
});
|
|
744
812
|
|
|
745
813
|
test("RFC Line 1136: Single use enforcement - code is marked as used", async () => {
|
|
@@ -42,6 +42,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
42
42
|
policyUrl?: string;
|
|
43
43
|
redirectUris: Array<string>;
|
|
44
44
|
scopes: Array<string>;
|
|
45
|
+
tokenEndpointAuthMethod?:
|
|
46
|
+
| "client_secret_basic"
|
|
47
|
+
| "client_secret_post"
|
|
48
|
+
| "none";
|
|
45
49
|
tosUrl?: string;
|
|
46
50
|
type: "confidential" | "public";
|
|
47
51
|
website?: string;
|
|
@@ -66,6 +70,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
66
70
|
code: string;
|
|
67
71
|
codeVerifier: string;
|
|
68
72
|
redirectUri?: string;
|
|
73
|
+
resource?: string;
|
|
69
74
|
},
|
|
70
75
|
any,
|
|
71
76
|
Name
|
|
@@ -81,11 +86,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
81
86
|
"mutation",
|
|
82
87
|
"internal",
|
|
83
88
|
{
|
|
89
|
+
authTime?: number;
|
|
84
90
|
clientId: string;
|
|
85
91
|
codeChallenge: string;
|
|
86
92
|
codeChallengeMethod: string;
|
|
87
93
|
nonce?: string;
|
|
88
94
|
redirectUri: string;
|
|
95
|
+
resource?: string;
|
|
89
96
|
scopes: Array<string>;
|
|
90
97
|
userId: string;
|
|
91
98
|
},
|
|
@@ -104,11 +111,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
104
111
|
"internal",
|
|
105
112
|
{
|
|
106
113
|
accessToken: string;
|
|
114
|
+
audience?: string;
|
|
107
115
|
clientId: string;
|
|
108
116
|
expiresAt: number;
|
|
109
117
|
oldRefreshToken: string;
|
|
110
118
|
refreshToken?: string;
|
|
111
119
|
refreshTokenExpiresAt?: number;
|
|
120
|
+
resource?: string;
|
|
112
121
|
scopes: Array<string>;
|
|
113
122
|
userId: string;
|
|
114
123
|
},
|
|
@@ -120,11 +129,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
120
129
|
"internal",
|
|
121
130
|
{
|
|
122
131
|
accessToken: string;
|
|
132
|
+
audience?: string;
|
|
123
133
|
authorizationCode?: string;
|
|
124
134
|
clientId: string;
|
|
125
135
|
expiresAt: number;
|
|
126
136
|
refreshToken?: string;
|
|
127
137
|
refreshTokenExpiresAt?: number;
|
|
138
|
+
resource?: string;
|
|
128
139
|
scopes: Array<string>;
|
|
129
140
|
userId: string;
|
|
130
141
|
},
|
|
@@ -141,7 +152,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
141
152
|
upsertAuthorization: FunctionReference<
|
|
142
153
|
"mutation",
|
|
143
154
|
"internal",
|
|
144
|
-
{
|
|
155
|
+
{
|
|
156
|
+
clientId: string;
|
|
157
|
+
resource?: string;
|
|
158
|
+
scopes: Array<string>;
|
|
159
|
+
userId: string;
|
|
160
|
+
},
|
|
145
161
|
any,
|
|
146
162
|
Name
|
|
147
163
|
>;
|
|
@@ -20,6 +20,7 @@ function isValidRedirectUri(uri: string): boolean {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (parsed.hash) return false;
|
|
23
|
+
if (parsed.username || parsed.password) return false;
|
|
23
24
|
|
|
24
25
|
const host = parsed.hostname.toLowerCase();
|
|
25
26
|
const isLoopback =
|
|
@@ -29,10 +30,23 @@ function isValidRedirectUri(uri: string): boolean {
|
|
|
29
30
|
|
|
30
31
|
if (parsed.protocol === "https:") return true;
|
|
31
32
|
if (parsed.protocol === "http:" && isLoopback) return true;
|
|
33
|
+
if (isValidPrivateUseRedirectUri(parsed)) return true;
|
|
32
34
|
|
|
33
35
|
return false;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
function isValidPrivateUseRedirectUri(parsed: URL): boolean {
|
|
39
|
+
const scheme = parsed.protocol.slice(0, -1);
|
|
40
|
+
const reverseDomainStyle = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*){2,}$/i;
|
|
41
|
+
return (
|
|
42
|
+
reverseDomainStyle.test(scheme) &&
|
|
43
|
+
parsed.hostname === "" &&
|
|
44
|
+
parsed.host === "" &&
|
|
45
|
+
parsed.pathname.startsWith("/") &&
|
|
46
|
+
parsed.pathname.length > 1
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* Register OAuth Client
|
|
38
52
|
*/
|
|
@@ -48,12 +62,27 @@ export const registerClient = mutation({
|
|
|
48
62
|
logoUrl: v.optional(v.string()),
|
|
49
63
|
tosUrl: v.optional(v.string()),
|
|
50
64
|
policyUrl: v.optional(v.string()),
|
|
65
|
+
tokenEndpointAuthMethod: v.optional(v.union(
|
|
66
|
+
v.literal("client_secret_basic"),
|
|
67
|
+
v.literal("client_secret_post"),
|
|
68
|
+
v.literal("none"),
|
|
69
|
+
)),
|
|
51
70
|
isInternal: v.optional(v.boolean()),
|
|
52
71
|
},
|
|
53
72
|
handler: async (ctx, args) => {
|
|
54
73
|
if (args.redirectUris.length === 0) {
|
|
55
74
|
throw new Error("redirect_uris required");
|
|
56
75
|
}
|
|
76
|
+
if (
|
|
77
|
+
args.type === "public" &&
|
|
78
|
+
args.tokenEndpointAuthMethod &&
|
|
79
|
+
args.tokenEndpointAuthMethod !== "none"
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("invalid_client_metadata: public clients must use token_endpoint_auth_method none");
|
|
82
|
+
}
|
|
83
|
+
if (args.type === "confidential" && args.tokenEndpointAuthMethod === "none") {
|
|
84
|
+
throw new Error("invalid_client_metadata: confidential clients must authenticate at the token endpoint");
|
|
85
|
+
}
|
|
57
86
|
const invalidRedirect = args.redirectUris.find((uri) => !isValidRedirectUri(uri));
|
|
58
87
|
if (invalidRedirect) {
|
|
59
88
|
throw new Error(`Invalid redirect_uri: ${invalidRedirect}`);
|
|
@@ -83,6 +112,7 @@ export const registerClient = mutation({
|
|
|
83
112
|
logoUrl: args.logoUrl,
|
|
84
113
|
tosUrl: args.tosUrl,
|
|
85
114
|
policyUrl: args.policyUrl,
|
|
115
|
+
tokenEndpointAuthMethod: args.tokenEndpointAuthMethod ?? "client_secret_basic",
|
|
86
116
|
isInternal: args.isInternal,
|
|
87
117
|
});
|
|
88
118
|
|
|
@@ -107,6 +137,7 @@ export const registerClient = mutation({
|
|
|
107
137
|
logoUrl: args.logoUrl,
|
|
108
138
|
tosUrl: args.tosUrl,
|
|
109
139
|
policyUrl: args.policyUrl,
|
|
140
|
+
tokenEndpointAuthMethod: args.tokenEndpointAuthMethod ?? "none",
|
|
110
141
|
isInternal: args.isInternal,
|
|
111
142
|
});
|
|
112
143
|
|