Skip to content
File

Blob: tests/worker/oauth-token.test.ts

typescript181 lines
1import { env } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "vitest";
3 
4import {
5 DEFAULT_PASSWORD,
6 DEFAULT_REDIRECT_URI,
7 ISSUER,
8 SELF,
9 authorizeWithPkce,
10 createOAuthClientAsAdmin,
11 exchangeAuthorizationCode,
12 signInForCookie,
13 signUpAdmin,
14 type JsonErrorBody,
15 type OAuthTokenResponse,
16 type TestCredential,
17} from "./helpers";
18 
19// /api/auth/oauth2/token must reject grant_type=client_credentials.
20// oauthProvider's default grantTypes include client_credentials, so a
21// confidential client could mint non-user bearer tokens via its secret
22// without ever signing a human in. tessera sets grantTypes:
23// ["authorization_code"] to remove that surface; this test pins the
24// behavior so a future config drift can't reopen it silently.
25describe("token endpoint grant types", () => {
26 const adminCred = {
27 email: "token-admin@example.com",
28 password: DEFAULT_PASSWORD,
29 name: "Token Admin",
30 } satisfies TestCredential;
31 let clientId: string;
32 let clientSecret: string;
33 
34 beforeAll(async () => {
35 await signUpAdmin(adminCred);
36 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.70.0.1");
37 
38 const created = await createOAuthClientAsAdmin(cookie, {
39 name: "token grant test client",
40 redirectUris: [DEFAULT_REDIRECT_URI],
41 });
42 clientId = created.client_id;
43 clientSecret = created.client_secret;
44 });
45 
46 it("rejects grant_type=client_credentials with unsupported_grant_type", async () => {
47 const basic = btoa(`${clientId}:${clientSecret}`);
48 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
49 method: "POST",
50 headers: {
51 "content-type": "application/x-www-form-urlencoded",
52 authorization: `Basic ${basic}`,
53 },
54 body: new URLSearchParams({ grant_type: "client_credentials", scope: "openid" }).toString(),
55 });
56 expect(res.status).toBe(400);
57 const body = (await res.json()) as JsonErrorBody;
58 expect(body.error).toBe("unsupported_grant_type");
59 });
60 
61 it("rejects grant_type=refresh_token (refresh disabled in tessera config)", async () => {
62 const basic = btoa(`${clientId}:${clientSecret}`);
63 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
64 method: "POST",
65 headers: {
66 "content-type": "application/x-www-form-urlencoded",
67 authorization: `Basic ${basic}`,
68 },
69 body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: "anything" }).toString(),
70 });
71 expect(res.status).toBe(400);
72 const body = (await res.json()) as JsonErrorBody;
73 expect(body.error).toBe("unsupported_grant_type");
74 });
75 
76 // The `resource` parameter triggers oauth-provider's stateless JWT
77 // access token path (createJwtAccessToken), which bypasses the D1 token
78 // rows the ban kill-switch deletes. tessera rejects `resource` at the
79 // token endpoint so every issued token remains revocable through D1
80 // state, including after Better Auth 1.7's resource-indicator hardening.
81 // These tests gate that invariant against config drift.
82 it("rejects token requests with a string `resource` parameter", async () => {
83 const basic = btoa(`${clientId}:${clientSecret}`);
84 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
85 method: "POST",
86 headers: {
87 "content-type": "application/x-www-form-urlencoded",
88 authorization: `Basic ${basic}`,
89 },
90 body: new URLSearchParams({
91 grant_type: "authorization_code",
92 code: "no-such-code",
93 resource: "https://api.example.com",
94 }).toString(),
95 });
96 expect(res.status).toBe(400);
97 const body = (await res.json()) as JsonErrorBody;
98 expect(body.error).toBe("invalid_request");
99 expect(body.code).toBe("RESOURCE_NOT_SUPPORTED");
100 });
101 
102 it("rejects token requests with multiple `resource` parameters (form array)", async () => {
103 // OAuth 2.0 resource indicators (RFC 8707) allow repeated `resource`
104 // parameters in form-encoded bodies; URLSearchParams represents this
105 // by appending the same key twice. Better Auth's body parser converts
106 // repeats into an array so the hook sees `body.resource` as a string[].
107 const basic = btoa(`${clientId}:${clientSecret}`);
108 const params = new URLSearchParams();
109 params.append("grant_type", "authorization_code");
110 params.append("code", "no-such-code");
111 params.append("resource", "https://api.example.com");
112 params.append("resource", "https://other.example.com");
113 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
114 method: "POST",
115 headers: {
116 "content-type": "application/x-www-form-urlencoded",
117 authorization: `Basic ${basic}`,
118 },
119 body: params.toString(),
120 });
121 expect(res.status).toBe(400);
122 const body = (await res.json()) as JsonErrorBody;
123 expect(body.error).toBe("invalid_request");
124 expect(body.code).toBe("RESOURCE_NOT_SUPPORTED");
125 });
126 
127 it("supports Post after a metadata-only correction without rotating the secret or revoking existing tokens", async () => {
128 const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.70.0.2");
129 const client = await createOAuthClientAsAdmin(cookie, {
130 name: "legacy Post client",
131 redirectUris: [DEFAULT_REDIRECT_URI],
132 skipConsent: true,
133 });
134 const first = await authorizeWithPkce({ clientId: client.client_id, cookie });
135 expect(first.code).toBeTruthy();
136 const firstResponse = await exchangeAuthorizationCode({
137 code: first.code!,
138 verifier: first.verifier,
139 clientId: client.client_id,
140 clientSecret: client.client_secret,
141 });
142 expect(firstResponse.status).toBe(200);
143 const firstTokens = (await firstResponse.json()) as OAuthTokenResponse;
144 const before = await env.DB.prepare("SELECT client_secret FROM oauth_clients WHERE client_id = ?")
145 .bind(client.client_id)
146 .first<{ client_secret: string }>();
147 
148 await env.DB.prepare(
149 "UPDATE oauth_clients SET token_endpoint_auth_method = 'client_secret_post' WHERE client_id = ?",
150 )
151 .bind(client.client_id)
152 .run();
153 const after = await env.DB.prepare("SELECT client_secret FROM oauth_clients WHERE client_id = ?")
154 .bind(client.client_id)
155 .first<{ client_secret: string }>();
156 expect(Boolean(before?.client_secret && before.client_secret === after?.client_secret)).toBe(true);
157 
158 const existingToken = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, {
159 headers: { authorization: `Bearer ${firstTokens.access_token}` },
160 });
161 expect(existingToken.status).toBe(200);
162 
163 const next = await authorizeWithPkce({ clientId: client.client_id, cookie });
164 expect(next.code).toBeTruthy();
165 const response = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
166 method: "POST",
167 headers: { "content-type": "application/x-www-form-urlencoded" },
168 body: new URLSearchParams({
169 grant_type: "authorization_code",
170 code: next.code!,
171 code_verifier: next.verifier,
172 redirect_uri: DEFAULT_REDIRECT_URI,
173 client_id: client.client_id,
174 client_secret: client.client_secret,
175 }),
176 });
177 expect(response.status).toBe(200);
178 expect(Boolean(((await response.json()) as OAuthTokenResponse).id_token)).toBe(true);
179 });
180});