Get tokens with a static OAuth client
You can obtain access and refresh tokens from the Glean OAuth Authorization Server by using a static OAuth client.
These examples use the authorization code flow with Proof Key for Code Exchange (PKCE), a localhost redirect URI, and client_secret_post so you can inspect each step of the exchange.
When you call the Glean Client API with an OAuth access token, include the header Authorization: Bearer <token>.
If your static client uses a different token endpoint authentication method, adjust the token and refresh requests to match your client configuration.
Prerequisites
Complete the following prerequisites:
- Enable the Glean OAuth Authorization Server. See Glean OAuth Authorization Server.
- Create a static OAuth client. See Static OAuth clients.
- Register the redirect URI
http://127.0.0.1:9090/oauth/callbackon your static client.
In the examples below, copy your full backend URL from app.glean.com/admin/about-glean under Server instance (QE), and replace https://tenant_id-be.glean.com with it.
Examples
See the following examples for Bash and TypeScript to learn how to get tokens with a static OAuth client.
These examples are not production code. They only show how to complete the OAuth authorization code flow and make a basic client API request. In production, follow best practices for credential storage, token management, redirect handling, and error handling.
- Bash
- TypeScript
Bash prerequisites
Install the following tools before you run the Bash example:
bashcurljqopensslnc
1. Set up variables
Register the redirect URI http://127.0.0.1:9090/oauth/callback:
export CLIENT_ID='YOUR_STATIC_CLIENT_ID'
export CLIENT_SECRET='YOUR_STATIC_CLIENT_SECRET'
export GLEAN_BACKEND_URL='https://tenant_id-be.glean.com'
export REDIRECT_URI='http://127.0.0.1:9090/oauth/callback'
export CALLBACK_CAPTURE='/tmp/glean-oauth-callback.request'
2. Discover OAuth metadata
Discover the OAuth metadata for the Glean OAuth Authorization Server:
export OAUTH_METADATA=$(curl -fsS "$GLEAN_BACKEND_URL/.well-known/oauth-authorization-server")
export AUTHORIZATION_ENDPOINT=$(jq -r '.authorization_endpoint' <<<"$OAUTH_METADATA")
export TOKEN_ENDPOINT=$(jq -r '.token_endpoint' <<<"$OAUTH_METADATA")
export ISSUER=$(jq -r '.issuer' <<<"$OAUTH_METADATA")
echo "issuer=$ISSUER"
echo "authorization_endpoint=$AUTHORIZATION_ENDPOINT"
echo "token_endpoint=$TOKEN_ENDPOINT"
echo "code challenge methods:"
jq -r '.code_challenge_methods_supported[]?' <<<"$OAUTH_METADATA"
echo "token endpoint auth methods:"
jq -r '.token_endpoint_auth_methods_supported[]?' <<<"$OAUTH_METADATA"
3. Generate PKCE values
Generate the PKCE values:
b64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
export STATE=$(openssl rand -base64 32 | tr -d '\n=' | tr '+/' '-_' | cut -c1-43)
export CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=' | tr '+/' '-_' | cut -c1-86)
export CODE_CHALLENGE=$(
printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | b64url
)
4. Start a localhost callback listener
Before you open the authorization URL, create a response file for the callback:
cat > /tmp/glean-oauth-callback.response <<'EOF'
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Connection: close
OAuth callback captured. You can return to the terminal.
EOF
rm -f "$CALLBACK_CAPTURE"
nc -l 127.0.0.1 9090 < /tmp/glean-oauth-callback.response > "$CALLBACK_CAPTURE"
If you do not want to use nc, you can use any local listener that accepts one request on 127.0.0.1:9090 and writes the raw HTTP request to "$CALLBACK_CAPTURE". For example, you can use python -m http.server 9090 to start a simple HTTP server.
5. Open the authorization URL
Open the authorization URL:
export AUTHORIZE_URL=$(
jq -rn \
--arg endpoint "$AUTHORIZATION_ENDPOINT" \
--arg client_id "$CLIENT_ID" \
--arg redirect_uri "$REDIRECT_URI" \
--arg state "$STATE" \
--arg code_challenge "$CODE_CHALLENGE" \
'$endpoint + "?" + (
[
["response_type", "code"],
["client_id", $client_id],
["redirect_uri", $redirect_uri],
["state", $state],
["code_challenge", $code_challenge],
["code_challenge_method", "S256"]
]
| map("\(.[0])=\(.[1] | @uri)")
| join("&")
)'
)
printf '%s\n' "$AUTHORIZE_URL"
This code opens the URL in your browser. On macOS, you can also run open $AUTHORIZE_URL.
Sign in and complete the consent flow in your browser. Glean should redirect back to http://127.0.0.1:9090/oauth/callback?..., and the nc process should exit after writing the request to "$CALLBACK_CAPTURE".
6. Extract the authorization code
Extract the authorization code:
# Extract the authorization code from the callback request
urldecode() {
local data="${1//+/ }"
printf '%b' "${data//%/\\x}"
}
export REQUEST_LINE=$(sed -n '1s/\r$//p' "$CALLBACK_CAPTURE")
export QUERY_STRING="${REQUEST_LINE#GET /oauth/callback?}"
export QUERY_STRING="${QUERY_STRING%% HTTP/*}"
export RAW_CODE=$(printf '%s\n' "$QUERY_STRING" | tr '&' '\n' | sed -n 's/^code=//p')
export RAW_STATE=$(printf '%s\n' "$QUERY_STRING" | tr '&' '\n' | sed -n 's/^state=//p')
export RAW_ERROR=$(printf '%s\n' "$QUERY_STRING" | tr '&' '\n' | sed -n 's/^error=//p')
export AUTH_CODE=$(urldecode "$RAW_CODE")
export RETURNED_STATE=$(urldecode "$RAW_STATE")
export RETURNED_ERROR=$(urldecode "$RAW_ERROR")
if [ -n "$RETURNED_ERROR" ]; then
echo "OAuth error: $RETURNED_ERROR" >&2
exit 1
fi
if [ "$RETURNED_STATE" != "$STATE" ]; then
echo "state mismatch" >&2
exit 1
fi
printf 'Captured auth code prefix: %s\n' "${AUTH_CODE:0:16}"
7. Exchange the code for tokens
Exchange the authorization code for tokens using client_secret_post:
export TOKEN_RESPONSE=$(
curl -fsS -X POST "$TOKEN_ENDPOINT" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$AUTH_CODE" \
--data-urlencode "redirect_uri=$REDIRECT_URI" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode "code_verifier=$CODE_VERIFIER"
)
jq . <<<"$TOKEN_RESPONSE"
export ACCESS_TOKEN=$(jq -r '.access_token' <<<"$TOKEN_RESPONSE")
export REFRESH_TOKEN=$(jq -r '.refresh_token // empty' <<<"$TOKEN_RESPONSE")
8. Call the Chat API
Use the access token to make a simple client API request:
export CHAT_RESPONSE=$(
curl -fsS -X POST "$GLEAN_BACKEND_URL/rest/api/v1/chat" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"messages": [{"fragments": [{"text": "Who is on the executive team?"}]}]
}'
)
jq . <<<"$CHAT_RESPONSE"
9. Inspect the access token
Decode the JWT locally so you can inspect the claims:
jwt_b64url_decode() {
local input="$1"
case $((${#input} % 4)) in
2) input="${input}==" ;;
3) input="${input}=" ;;
1)
echo "invalid base64url input" >&2
return 1
;;
esac
printf '%s' "$input" | tr '_-' '/+' | openssl base64 -d -A
}
export JWT_HEADER_B64=$(cut -d '.' -f1 <<<"$ACCESS_TOKEN")
export JWT_PAYLOAD_B64=$(cut -d '.' -f2 <<<"$ACCESS_TOKEN")
echo "jwt header:"
jwt_b64url_decode "$JWT_HEADER_B64" | jq .
echo "jwt claims:"
jwt_b64url_decode "$JWT_PAYLOAD_B64" | jq .
This does not verify the signature.
The decoded header includes a kid (key ID). To verify the signature, fetch the matching key from the Glean JWKS (the key whose kid equals the token's) and validate the token against it. See Verify tokens against the JWKS.
10. Use the refresh token
Use the refresh token to obtain a new access token:
if [ -z "$REFRESH_TOKEN" ]; then
echo "No refresh token was returned"
exit 1
fi
export REFRESH_RESPONSE=$(
curl -fsS -X POST "$TOKEN_ENDPOINT" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET"
)
jq . <<<"$REFRESH_RESPONSE"
export REFRESHED_ACCESS_TOKEN=$(jq -r '.access_token' <<<"$REFRESH_RESPONSE")
export REFRESHED_REFRESH_TOKEN=$(jq -r '.refresh_token // empty' <<<"$REFRESH_RESPONSE")
TypeScript prerequisites
Complete the following prerequisites before you run the TypeScript example:
- Install Node 24 or later. This example runs directly with
nodeand does not require any external packages. - If you are using an earlier Node version, run the script with
node --experimental-strip-types.
1. Save the script
Save the following script as oauth-static-client.ts:
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';
import process from 'node:process';
type OAuthMetadata = {
authorization_endpoint: string;
token_endpoint: string;
issuer: string;
code_challenge_methods_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
};
type TokenResponse = {
access_token: string;
refresh_token?: string;
[key: string]: unknown;
};
const CLIENT_ID = process.env.CLIENT_ID ?? 'YOUR_STATIC_CLIENT_ID';
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? 'YOUR_STATIC_CLIENT_SECRET';
const GLEAN_BACKEND_URL =
process.env.GLEAN_BACKEND_URL ?? 'https://tenant_id-be.glean.com';
const REDIRECT_URI =
process.env.REDIRECT_URI ?? 'http://127.0.0.1:9090/oauth/callback';
function base64Url(input: Buffer): string {
return input.toString('base64url');
}
function decodeJwtPart(part: string): unknown {
if (!part) {
throw new Error('Missing JWT segment');
}
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const text = await response.text();
if (!response.ok) {
throw new Error(`Request to ${url} failed (${response.status}): ${text}`);
}
return JSON.parse(text) as T;
}
async function waitForOAuthCallback(
redirectUri: string,
expectedState: string,
): Promise<string> {
const redirectUrl = new URL(redirectUri);
const port = Number.parseInt(redirectUrl.port || '80', 10);
return await new Promise<string>((resolve, reject) => {
const server = createServer((req, res) => {
try {
const callbackUrl = new URL(
req.url ?? '/',
`${redirectUrl.protocol}//${redirectUrl.host}`,
);
if (callbackUrl.pathname !== redirectUrl.pathname) {
res.writeHead(404).end('Not found\n');
return;
}
const error = callbackUrl.searchParams.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
const returnedState = callbackUrl.searchParams.get('state');
if (returnedState !== expectedState) {
throw new Error('State mismatch');
}
const code = callbackUrl.searchParams.get('code');
if (!code) {
throw new Error('Missing authorization code in callback');
}
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
});
res.end('OAuth callback captured. You can return to the terminal.\n');
server.close(() => resolve(code));
} catch (error) {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
});
res.end('OAuth callback failed. Check the terminal for details.\n');
server.close(() => reject(error));
}
});
server.on('error', reject);
server.listen(port, redirectUrl.hostname, () => {
console.log(`Listening for callback at ${redirectUri}`);
});
});
}
function logJwt(accessToken: string): void {
const [headerB64, payloadB64] = accessToken.split('.');
console.log('\nJWT header:');
console.log(JSON.stringify(decodeJwtPart(headerB64 ?? ''), null, 2));
console.log('\nJWT claims:');
console.log(JSON.stringify(decodeJwtPart(payloadB64 ?? ''), null, 2));
}
async function callChatApi(accessToken: string): Promise<unknown> {
return await fetchJson<unknown>(`${GLEAN_BACKEND_URL}/rest/api/v1/chat`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{
fragments: [
{
text: "Who is on the executive team?"
}
]
}
]
}),
});
}
async function main(): Promise<void> {
if (
CLIENT_ID === 'YOUR_STATIC_CLIENT_ID' ||
CLIENT_SECRET === 'YOUR_STATIC_CLIENT_SECRET' ||
GLEAN_BACKEND_URL === 'https://tenant_id-be.glean.com'
) {
throw new Error(
'Set CLIENT_ID, CLIENT_SECRET, and GLEAN_BACKEND_URL before you run this script.',
);
}
const metadata = await fetchJson<OAuthMetadata>(
`${GLEAN_BACKEND_URL}/.well-known/oauth-authorization-server`,
);
console.log(`issuer=${metadata.issuer}`);
console.log(`authorization_endpoint=${metadata.authorization_endpoint}`);
console.log(`token_endpoint=${metadata.token_endpoint}`);
console.log('\ncode challenge methods:');
for (const method of metadata.code_challenge_methods_supported ?? []) {
console.log(method);
}
console.log('\ntoken endpoint auth methods:');
for (const method of metadata.token_endpoint_auth_methods_supported ?? []) {
console.log(method);
}
const state = base64Url(randomBytes(32));
const codeVerifier = base64Url(randomBytes(64));
const codeChallenge = base64Url(
createHash('sha256').update(codeVerifier).digest(),
);
const authorizeUrl = new URL(metadata.authorization_endpoint);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
console.log('\nOpen this URL in your browser:\n');
console.log(authorizeUrl.toString());
const authCode = await waitForOAuthCallback(REDIRECT_URI, state);
console.log(`\nCaptured auth code prefix: ${authCode.slice(0, 16)}`);
// This example uses client_secret_post for token endpoint authentication.
const tokenResponse = await fetchJson<TokenResponse>(metadata.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: codeVerifier,
}).toString(),
});
console.log('\nToken response:');
console.log(JSON.stringify(tokenResponse, null, 2));
const chatResponse = await callChatApi(tokenResponse.access_token);
console.log('\nChat response:');
console.log(JSON.stringify(chatResponse, null, 2));
logJwt(tokenResponse.access_token);
if (!tokenResponse.refresh_token) {
console.log('\nNo refresh token was returned');
return;
}
const refreshResponse = await fetchJson<TokenResponse>(
metadata.token_endpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokenResponse.refresh_token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}).toString(),
},
);
console.log('\nRefresh response:');
console.log(JSON.stringify(refreshResponse, null, 2));
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
});
2. Set your environment variables
Set your environment variables:
export CLIENT_ID='YOUR_STATIC_CLIENT_ID'
export CLIENT_SECRET='YOUR_STATIC_CLIENT_SECRET'
export GLEAN_BACKEND_URL='https://tenant_id-be.glean.com'
3. Run the script
For Node 24 or later, run the script:
node oauth-static-client.ts
For Node versions earlier than Node 24, run the script with the --experimental-strip-types flag:
node --experimental-strip-types oauth-static-client.ts
The script does the following:
- Prints the authorization URL.
- Starts a localhost callback listener.
- Exchanges the authorization code for tokens.
- Calls the Chat API.
- Decodes the access token locally.
- Uses the refresh token if one is returned.
After you obtain an access token, send it as a bearer token when you call the Glean Client API. Include the header Authorization: Bearer <token>.