mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
264 lines
8.0 KiB
TypeScript
264 lines
8.0 KiB
TypeScript
import {
|
|
DEVICE_CODE_GRANT_TYPE,
|
|
oauthDeviceAuthorization,
|
|
oauthProvider,
|
|
} from "@better-auth/oauth-provider";
|
|
import { expect, test } from "@playwright/test";
|
|
import { jwt } from "better-auth/plugins/jwt";
|
|
import { createLocalJWKSet, jwtVerify } from "jose";
|
|
import { setupServer } from "./utils";
|
|
|
|
const FORM_HEADERS = {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
};
|
|
|
|
type AuthorizationServerMetadata = {
|
|
issuer: string;
|
|
registration_endpoint: string;
|
|
device_authorization_endpoint: string;
|
|
token_endpoint: string;
|
|
jwks_uri: string;
|
|
grant_types_supported: string[];
|
|
};
|
|
|
|
type DeviceAuthorizationResponse = {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
verification_uri_complete: string;
|
|
interval: number;
|
|
};
|
|
|
|
type OAuthTokenResponse = {
|
|
access_token: string;
|
|
token_type: string;
|
|
scope: string;
|
|
};
|
|
|
|
async function responseJson<T>(response: Response): Promise<T> {
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
test.describe("OAuth device-code grant over HTTP", () => {
|
|
/**
|
|
* @see https://github.com/better-auth/better-auth/pull/10135
|
|
*/
|
|
test("registers, approves, and exchanges a resource-bound device code", async ({
|
|
browser,
|
|
}) => {
|
|
const resource = "https://api.example.com";
|
|
const { port, stop } = await setupServer(
|
|
{
|
|
baseURL: {
|
|
allowedHosts: ["127.0.0.1:*"],
|
|
protocol: "http",
|
|
},
|
|
plugins: [
|
|
jwt(),
|
|
oauthProvider({
|
|
loginPage: "/login",
|
|
consentPage: "/consent",
|
|
allowDynamicClientRegistration: true,
|
|
allowUnauthenticatedClientRegistration: true,
|
|
resources: [resource],
|
|
clientRegistrationAllowedResources: [resource],
|
|
scopes: ["openid", "profile", "email"],
|
|
}),
|
|
oauthDeviceAuthorization({
|
|
expiresIn: "5min",
|
|
interval: "1s",
|
|
}),
|
|
],
|
|
},
|
|
{ disableTestUser: true },
|
|
);
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
const authBaseURL = `${origin}/api/auth`;
|
|
const approvalContext = await browser.newContext({ baseURL: origin });
|
|
|
|
try {
|
|
const metadataResponse = await fetch(
|
|
`${authBaseURL}/.well-known/oauth-authorization-server`,
|
|
);
|
|
expect(metadataResponse.status).toBe(200);
|
|
const metadata =
|
|
await responseJson<AuthorizationServerMetadata>(metadataResponse);
|
|
expect(metadata).toMatchObject({
|
|
issuer: authBaseURL,
|
|
registration_endpoint: `${authBaseURL}/oauth2/register`,
|
|
device_authorization_endpoint: `${authBaseURL}/device/code`,
|
|
token_endpoint: `${authBaseURL}/oauth2/token`,
|
|
jwks_uri: `${authBaseURL}/jwks`,
|
|
});
|
|
expect(metadata.grant_types_supported).toContain(DEVICE_CODE_GRANT_TYPE);
|
|
|
|
const registrationResponse = await fetch(metadata.registration_endpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
client_name: "Device CLI",
|
|
application_type: "native",
|
|
redirect_uris: [`${origin}/callback`],
|
|
token_endpoint_auth_method: "none",
|
|
grant_types: [DEVICE_CODE_GRANT_TYPE],
|
|
scope: "openid profile email",
|
|
resources: [resource],
|
|
}),
|
|
});
|
|
expect(registrationResponse.status).toBe(201);
|
|
const registeredClient = await responseJson<{
|
|
client_id: string;
|
|
client_secret?: string;
|
|
token_endpoint_auth_method: string;
|
|
}>(registrationResponse);
|
|
expect(registeredClient.client_id).toBeTruthy();
|
|
expect(registeredClient.client_secret).toBeUndefined();
|
|
expect(registeredClient.token_endpoint_auth_method).toBe("none");
|
|
|
|
const authorizationResponse = await fetch(
|
|
metadata.device_authorization_endpoint,
|
|
{
|
|
method: "POST",
|
|
headers: FORM_HEADERS,
|
|
body: new URLSearchParams({
|
|
client_id: registeredClient.client_id,
|
|
scope: "openid profile email",
|
|
resource,
|
|
}),
|
|
},
|
|
);
|
|
expect(authorizationResponse.status).toBe(200);
|
|
const deviceAuthorizationResult =
|
|
await responseJson<DeviceAuthorizationResponse>(authorizationResponse);
|
|
expect(deviceAuthorizationResult.verification_uri).toBe(
|
|
`${origin}/device`,
|
|
);
|
|
expect(deviceAuthorizationResult.verification_uri_complete).toContain(
|
|
`user_code=${deviceAuthorizationResult.user_code}`,
|
|
);
|
|
expect(deviceAuthorizationResult.interval).toBe(1);
|
|
|
|
const pollDeviceToken = () =>
|
|
fetch(metadata.token_endpoint, {
|
|
method: "POST",
|
|
headers: FORM_HEADERS,
|
|
body: new URLSearchParams({
|
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
device_code: deviceAuthorizationResult.device_code,
|
|
client_id: registeredClient.client_id,
|
|
resource,
|
|
}),
|
|
});
|
|
|
|
const pendingResponse = await pollDeviceToken();
|
|
expect(pendingResponse.status).toBe(400);
|
|
expect(
|
|
await responseJson<{ error: string }>(pendingResponse),
|
|
).toMatchObject({
|
|
error: "authorization_pending",
|
|
});
|
|
|
|
const signUpResponse = await approvalContext.request.post(
|
|
`${authBaseURL}/sign-up/email`,
|
|
{
|
|
headers: { origin },
|
|
data: {
|
|
name: "Device User",
|
|
email: "device-user@example.com",
|
|
password: "correct-horse-battery-staple",
|
|
},
|
|
},
|
|
);
|
|
expect(signUpResponse.ok(), await signUpResponse.text()).toBe(true);
|
|
|
|
const userCode = deviceAuthorizationResult.user_code;
|
|
const consentResponse = await approvalContext.request.get(
|
|
`${authBaseURL}/oauth2/authorize?user_code=${encodeURIComponent(userCode)}`,
|
|
{ headers: { accept: "application/json" } },
|
|
);
|
|
expect(consentResponse.ok(), await consentResponse.text()).toBe(true);
|
|
const consentURL = new URL(
|
|
((await consentResponse.json()) as { url: string }).url,
|
|
origin,
|
|
);
|
|
expect(consentURL.pathname).toBe("/consent");
|
|
expect(Object.fromEntries(consentURL.searchParams)).toMatchObject({
|
|
user_code: userCode,
|
|
client_id: registeredClient.client_id,
|
|
scope: "openid profile email",
|
|
resource,
|
|
});
|
|
|
|
const approvalResponse = await approvalContext.request.post(
|
|
`${authBaseURL}/oauth2/consent`,
|
|
{
|
|
headers: { origin, accept: "application/json" },
|
|
data: { accept: true, oauth_query: consentURL.search.slice(1) },
|
|
},
|
|
);
|
|
expect(approvalResponse.ok(), await approvalResponse.text()).toBe(true);
|
|
expect(await approvalResponse.json()).toEqual({
|
|
redirect: true,
|
|
url: `${origin}/device?user_code=${encodeURIComponent(userCode)}&status=approved`,
|
|
});
|
|
|
|
const standaloneTokenResponse = await fetch(
|
|
`${authBaseURL}/device/token`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
device_code: deviceAuthorizationResult.device_code,
|
|
client_id: registeredClient.client_id,
|
|
}),
|
|
},
|
|
);
|
|
expect(standaloneTokenResponse.status).toBe(400);
|
|
expect(
|
|
await responseJson<{ error: string; error_description: string }>(
|
|
standaloneTokenResponse,
|
|
),
|
|
).toMatchObject({
|
|
error: "invalid_grant",
|
|
error_description: expect.stringContaining("/oauth2/token"),
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
const tokenResponse = await pollDeviceToken();
|
|
expect(tokenResponse.status).toBe(200);
|
|
const tokens = await responseJson<OAuthTokenResponse>(tokenResponse);
|
|
expect(tokens).toMatchObject({
|
|
token_type: "Bearer",
|
|
scope: "openid profile email",
|
|
access_token: expect.any(String),
|
|
});
|
|
|
|
const jwksResponse = await fetch(metadata.jwks_uri);
|
|
expect(jwksResponse.status).toBe(200);
|
|
const jwks = createLocalJWKSet(await jwksResponse.json());
|
|
const verifiedAccessToken = await jwtVerify(tokens.access_token, jwks, {
|
|
issuer: metadata.issuer,
|
|
audience: resource,
|
|
});
|
|
expect(verifiedAccessToken.protectedHeader.typ).toBe("at+jwt");
|
|
expect(verifiedAccessToken.payload).toMatchObject({
|
|
client_id: registeredClient.client_id,
|
|
scope: "openid profile email",
|
|
});
|
|
expect(verifiedAccessToken.payload.sub).toBeTruthy();
|
|
|
|
const replayResponse = await pollDeviceToken();
|
|
expect(replayResponse.status).toBe(400);
|
|
expect(
|
|
await responseJson<{ error: string }>(replayResponse),
|
|
).toMatchObject({
|
|
error: "invalid_grant",
|
|
});
|
|
} finally {
|
|
await approvalContext.close();
|
|
await stop();
|
|
}
|
|
});
|
|
});
|