feat: allow introspection and device code endpoints to use Federated Client Credentials (#640)

This commit is contained in:
Alessandro (Ale) Segala
2025-06-09 12:17:55 -07:00
committed by GitHub
parent df5c1ed1f8
commit b62b61fb01
13 changed files with 802 additions and 139 deletions

View File

@@ -87,11 +87,13 @@ export const refreshTokens = [
{
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: false
},
{
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: true
}
];

View File

@@ -156,11 +156,21 @@ test("End session with id token hint redirects to callback URL", async ({
test("Successfully refresh tokens with valid refresh token", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -168,7 +178,7 @@ test("Successfully refresh tokens with valid refresh token", async ({
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
@@ -184,26 +194,25 @@ test("Successfully refresh tokens with valid refresh token", async ({
expect(tokenData.refresh_token).not.toBe(token);
});
test("Using refresh token invalidates it for future use", async ({
test("Refresh token fails when used for the wrong client", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
client_secret: clientSecret,
},
});
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: 'bad-client',
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -211,7 +220,86 @@ test("Using refresh token invalidates it for future use", async ({
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
expect(refreshResponse.status()).toBe(400);
});
test("Refresh token fails when used for the wrong user", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: 'bad-user',
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
expect(refreshResponse.status()).toBe(400);
});
test("Using refresh token invalidates it for future use", async ({
request,
}) => {
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
// Try again
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
@@ -219,11 +307,10 @@ test("Using refresh token invalidates it for future use", async ({
});
test.describe("Introspection endpoint", () => {
const client = oidcClients.nextcloud;
test("without client_id and client_secret fails", async ({ request }) => {
test("fails without client credentials", async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
@@ -237,20 +324,20 @@ test.describe("Introspection endpoint", () => {
expect(introspectionResponse.status()).toBe(400);
});
test("with client_id and client_secret succeeds", async ({
test("succeeds with client credentials", async ({
request,
baseURL,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: validAccessToken,
@@ -266,18 +353,102 @@ test.describe("Introspection endpoint", () => {
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test("succeeds with federated client credentials", async ({
page,
request,
baseURL,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.federated.id
);
const clientAssertion = await oidcUtil.getClientAssertion(page, oidcClients.federated.federatedJWT);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Bearer " + clientAssertion,
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe("access_token");
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
});
test("fails with client credentials for wrong app", async ({
request,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${oidcClients.immich.id}:${oidcClients.immich.secret}`).toString("base64"),
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
test("fails with federated credentials for wrong app", async ({
page,
request,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.nextcloud.id
);
const clientAssertion = await oidcUtil.getClientAssertion(page, oidcClients.federated.federatedJWT);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Bearer " + clientAssertion,
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
test("non-expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => !token.expired)[0];
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: token,
token: refreshToken,
},
});
@@ -288,17 +459,28 @@ test.describe("Introspection endpoint", () => {
});
test("expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => token.expired)[0];
const { token, clientId, userId } = refreshTokens.filter(
(token) => token.expired
)[0];
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: token,
token: refreshToken,
},
});
@@ -310,7 +492,7 @@ test.describe("Introspection endpoint", () => {
test("expired access_token can't be verified", async ({ request }) => {
const expiredAccessToken = await generateOauthAccessToken(
users.tim,
client.id,
oidcClients.nextcloud.id,
true
);
const introspectionResponse = await request.post("/api/oidc/introspect", {