Fixes for Vault OIDC guide.

This commit is contained in:
slawek committed 2026-10-17 21:14:13 +02:00
1 parent 89b7681628
commit 5f8c65674c
5 files changed
+140 -16

No files matched your search

+3
View File
@@ -9,6 +9,9 @@ iam_policy.yaml
env.sh
vault-data/
vault-init.txt
entra.sh
*.hcl
*.csr
editor.json
oidc-config.json
oidc-role.json
+2 -1
View File
@@ -193,7 +193,8 @@ bash make_wif.sh
2. Checks the access to the project and the bucket, including an upload and a download of a file.
3. Switches back to the original gcloud configuration.
4. Lists the audit log of the service account operations.
5. Optionally switches back to the service account configuration.
5. Lists the audit log of all data operations on the bucket, regardless of the principal.
6. Optionally switches back to the service account configuration.
```bash
bash test_wif.sh
+30 -15
View File
@@ -7,7 +7,7 @@ This guide shows how an administrator configures the Vault OIDC authentication m
- The Vault PKI configured as described in step 1a of the main `README.md`: the `pki_int` mount and the `wif-client` role (`vault_configure.sh`).
- A Vault server reachable over HTTPS. The examples use `https://vault.example.com:8200`.
- Permissions to register applications in the Entra ID tenant.
- `vault` and `openssl` on the workstation of the administrator and of the user, and a web browser on the workstation of the user.
- `vault` and `openssl` on the workstation of the administrator and of the user, Node.js 24 or later and a web browser on the workstation of the user.
The commands are written for bash.
@@ -23,7 +23,7 @@ Role: **Administrator**
3. Record the **Application (client) ID** and the **Directory (tenant) ID**.
4. Under **Certificates & secrets**, create a client secret and record its value.
5. Under **Token configuration**, add a groups claim and select the ID token.
5. Under **Token configuration**, select **Add groups claim**, select the group types to include (**Security groups**), and under **Customize token properties by type** select **Group ID** in the **ID** section. The `groups` claim of the ID token then contains the object IDs of the groups of the user.
6. Create a security group of the users who may request certificates, add the users to it, and record the **Object ID** of the group.
7. Under **API permissions**, add the Microsoft Graph delegated permission `User.Read` and grant the admin consent. Entra ID omits the groups from the token of users who are members of more than 200 groups, and Vault then reads them through Graph.
@@ -50,12 +50,9 @@ path "pki_int/sign/wif-client" {
}
```
```bash
vault policy write pki-client pki-client.hcl
`oidc-config.json`:
vault auth enable oidc
vault write auth/oidc/config - <<'EOF'
```json
{
"oidc_discovery_url": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"oidc_client_id": "<client-id>",
@@ -65,9 +62,11 @@ vault write auth/oidc/config - <<'EOF'
"provider": "azure"
}
}
EOF
```
vault write auth/oidc/role/entra-pki - <<'EOF'
`oidc-role.json`:
```json
{
"role_type": "oidc",
"user_claim": "sub",
@@ -82,12 +81,23 @@ vault write auth/oidc/role/entra-pki - <<'EOF'
"token_ttl": "15m",
"token_max_ttl": "1h"
}
EOF
```
```bash
vault policy write pki-client pki-client.hcl
vault auth enable oidc
vault write auth/oidc/config @oidc-config.json
vault write auth/oidc/role/entra-pki @oidc-role.json
```
Delete `oidc-config.json` after the configuration is written, because it contains the client secret.
The redirect URIs in the role must match the ones registered in Entra ID exactly, including the scheme, the port and the trailing path. The `bound_claims` entry limits the login to the members of the group, the other Entra ID users get an error at login.
The commands above pass JSON on standard input, because the CLI cannot express the `bound_claims` map as `key=value` arguments.
The CLI cannot express the `bound_claims` map as `key=value` arguments, which is why the configuration is read from JSON files.
> **Note:** The `wif-client` role accepts any common name (`allow_any_name=true`). Every member of the group can therefore request a certificate with any subject CN, including the CN that other users or workloads rely on as the WiF subject. Restrict the role (`allowed_domains`, `allow_bare_domains`, `enforce_hostnames`) when the users must not choose their CN freely.
@@ -95,20 +105,24 @@ The commands above pass JSON on standard input, because the CLI cannot express t
Role: **User**
1. Set the address of the Vault server. Set `VAULT_CACERT` as well when the server certificate is issued by a private CA.
1. Set the address of the Vault server. Set `VAULT_CACERT` and `NODE_EXTRA_CA_CERTS` as well when the server certificate is issued by a private CA. `vault_oidc_login.mjs` runs on Node.js, which does not read `VAULT_CACERT`.
```bash
export VAULT_ADDR=https://vault.example.com:8200
export VAULT_CACERT=/path/to/server-ca.pem
export NODE_EXTRA_CA_CERTS=/path/to/server-ca.pem
```
2. Unset `VAULT_TOKEN`, because a token in this environment variable takes precedence over the token stored by `vault login` and the CLI would keep using it. Then log in with the Entra ID account. The CLI starts a listener on `localhost:8250` and opens the browser with the Entra ID sign-in page. After the sign-in the CLI stores the token in the token helper file (`~/.vault-token`).
2. Log in with the Entra ID account using `vault_oidc_login.mjs`. The script does not launch a browser. It prints the Entra ID sign-in URL, waits on `localhost:8250` for the redirect, confirms the login with the policies and the lifetime of the token, and prints the Vault token. The messages go to the standard error output, so the command substitution below receives only the token.
```bash
unset VAULT_TOKEN
vault login -method=oidc role=entra-pki
export VAULT_TOKEN=$(node vault_oidc_login.mjs --role entra-pki)
```
Open the printed URL in the browser, or in the browser profile or the private window, signed in with the account that is a member of the group. Entra ID signs in with the session that the browser already has, without asking. A token of another account fails with `claim "groups" does not match any associated bound claim values`, because that account is not a member of the group.
The script has the optional `--mount` (default `oidc`), `--redirect-uri` (default `http://localhost:8250/oidc/callback`) and `--timeout` (default `300` seconds) options. The redirect URI must be registered in Entra ID and listed in `allowed_redirect_uris` of the role.
3. Generate the client key and the CSR locally. The key is never sent to Vault.
```bash
@@ -139,6 +153,7 @@ Role: **User**
```bash
vault token revoke -self
unset VAULT_TOKEN
```
The user token can only sign with the `wif-client` role. Reading the role or any other path returns `permission denied`.
+14
View File
@@ -28,6 +28,8 @@ cmp README.md README.downloaded.md
gcloud config configurations activate default
# List the audit log of the operations made by the service account
# The same query for the Logs Explorer in the console, replace SA_EMAIL:
# protoPayload.authenticationInfo.principalEmail="SA_EMAIL"
gcloud logging read \
"protoPayload.authenticationInfo.principalEmail=\"${SA_EMAIL}\"" \
--project="${PROJECT_ID}" \
@@ -35,5 +37,17 @@ gcloud logging read \
--order=asc \
--format="table(timestamp, protoPayload.serviceName, protoPayload.methodName, protoPayload.authenticationInfo.principalEmail:label=EMAIL, protoPayload.authenticationInfo.principalSubject:label=SUBJECT, protoPayload.authenticationInfo.serviceAccountDelegationInfo[0].principalSubject:label=DELEGATED_BY)"
# List the audit log of all data operations on the bucket, regardless of the principal
# The same query for the Logs Explorer in the console, replace PROJECT_ID and BUCKET:
# logName="projects/PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access"
# resource.type="gcs_bucket"
# resource.labels.bucket_name="BUCKET"
gcloud logging read \
"logName=\"projects/${PROJECT_ID}/logs/cloudaudit.googleapis.com%2Fdata_access\" AND resource.type=\"gcs_bucket\" AND resource.labels.bucket_name=\"${BUCKET}\"" \
--project="${PROJECT_ID}" \
--freshness=1h \
--order=asc \
--format="table(timestamp, protoPayload.methodName, protoPayload.resourceName:label=RESOURCE, protoPayload.authenticationInfo.principalEmail:label=EMAIL, protoPayload.authenticationInfo.principalSubject:label=SUBJECT, protoPayload.authenticationInfo.serviceAccountDelegationInfo[0].principalSubject:label=DELEGATED_BY)"
# optionally switch back to the service account for further testing
# gcloud config configurations activate wif-test
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
role: { type: "string" },
mount: { type: "string", default: "oidc" },
"redirect-uri": { type: "string", default: "http://localhost:8250/oidc/callback" },
timeout: { type: "string", default: "300" },
},
});
const address = process.env.VAULT_ADDR;
if (!values.role || !address) {
console.error(
"Usage: VAULT_ADDR=... vault_oidc_login.mjs --role ROLE [--mount MOUNT] [--redirect-uri URI] [--timeout SECONDS]",
);
process.exit(1);
}
const redirect = new URL(values["redirect-uri"]);
const clientNonce = randomBytes(16).toString("hex");
const base = `${address}/v1/auth/${values.mount}/oidc`;
// Ask Vault for the sign-in URL of the identity provider.
const authResponse = await fetch(`${base}/auth_url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
role: values.role,
redirect_uri: values["redirect-uri"],
client_nonce: clientNonce,
}),
});
if (!authResponse.ok) {
console.error(`Vault returned ${authResponse.status}: ${await authResponse.text()}`);
process.exit(1);
}
const { data } = await authResponse.json();
console.error(`Open this URL in the browser or in the browser profile of your choice:\n\n ${data.auth_url}\n`);
console.error("Waiting for the sign-in to complete...");
// Wait for the identity provider to redirect the browser to the local callback.
const params = await new Promise((resolve, reject) => {
const server = createServer((request, response) => {
const url = new URL(request.url, redirect);
if (url.pathname !== redirect.pathname) {
response.writeHead(404).end();
return;
}
response.writeHead(200, { "Content-Type": "text/plain", Connection: "close" });
response.end("The sign-in is complete, you can close this window.\n");
server.close();
server.closeAllConnections();
resolve(url.searchParams);
});
server.listen(Number(redirect.port), redirect.hostname);
setTimeout(() => {
server.close();
server.closeAllConnections();
reject(new Error("Timed out waiting for the sign-in"));
}, Number(values.timeout) * 1000).unref();
}).catch((error) => {
console.error(error.message);
process.exit(1);
});
if (params.get("error")) {
console.error(`The identity provider returned ${params.get("error")}: ${params.get("error_description")}`);
process.exit(1);
}
// Let Vault exchange the authorization code and issue the token.
const callback = new URLSearchParams({
state: params.get("state"),
code: params.get("code"),
client_nonce: clientNonce,
});
const callbackResponse = await fetch(`${base}/callback?${callback}`);
if (!callbackResponse.ok) {
console.error(`Vault returned ${callbackResponse.status}: ${await callbackResponse.text()}`);
process.exit(1);
}
const { auth } = await callbackResponse.json();
console.error(
`Login successful. Policies: ${auth.policies.join(", ")}. The token is valid for ${auth.lease_duration} seconds.`,
);
console.log(auth.client_token);