A morning rabbit hole because quite frankly, I forgot how to verify a RS256-signed JWT from application code... and I work at an auth company.
Log in to https://console.neon.tech — my guinea pig — poke around for my own authentication details.
Grab the keycloak_token
cookie, URL-decoded:
{"AccessToken":"<REDACTED>","RefreshToken":"<REDACTED>"}
Decode the access token to glean some information, like where the public key for JWT verification might be:
user@~: $ jwt decode <REDACTED AccessToken>
Token header
------------
{
"typ": "JWT",
"alg": "RS256",
"kid": "<<SOME ID>>"
}
Token claims
------------
{
"acr": "1",
"allowed-origins": [
"https://console.neon.tech"
],
"aud": [
"neon-console",
"broker",
"account"
],
"auth_time": 1732973833,
"azp": "neon-console",
"email": "<<EMAIL>>",
"email_verified": true,
"exp": 1732981329,
"family_name": "Wang",
"given_name": "Kevin",
"iat": 1732981029,
"identity_provider": "github",
"identity_provider_uid": "26389321",
"iss": "https://console.neon.tech/realms/prod-realm",
"jti": "<<UUID>>",
"name": "Kevin Wang",
"preferred_username": "<<EMAIL>>",
"realm_access": {
"roles": [
"default-roles-prod-realm",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
},
"broker": {
"roles": [
"read-token"
]
}
},
"scope": "openid email profile",
"sid": "<<UUID>>",
"sub": "<<UUID>>",
"typ": "Bearer"
}
Visit https://console.neon.tech/realms/prod-realm:
https://console.neon.tech/realms/prod-realm{
"realm": "prod-realm",
"public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+bKt7koQ9rfZO0QYvLR5liu4KtzXimn1+Syg5ghiHZmc9PQaMKRTHydNAmj+Gd7Po38BPq7u+nmMhFKzLbtURAlxK5hd2zcEvEbaKx7YoLJwtf3YeWAYGYCn2CBwx/IsS8paOC2DfLSeubpe902wb/ZVaLLKTq7LauQOsTJ59nrBfuVS7o/zC6XQePRt2ot+FlU9mlb3nuUl3VJ/2HvTNIjMFhk9Hcv8xENh4xZ2dKKTOqjgpQuvwyetLQNbBHm4zzwlcVijdpvdOepCKNEV7l8O+AuGjx9A54JGU2ASRY39cPy7SIn9iVA3rUilTvLOOEeRkuxpKrXEfBOoE/fqnwIDAQAB",
"token-service": "https://console.neon.tech/realms/prod-realm/protocol/openid-connect",
"account-service": "https://console.neon.tech/realms/prod-realm/account",
"tokens-not-before": 0
}
Ask ChatGPT what format the public key is in, and be told that it's Base64-encoded PEM format.
Tell ChatGPT that base64-decoding returns gibberish, and be told that it's DER-encoded and receive a neat trick to inspect.
user@~: $ echo $(curl -s https://console.neon.tech/realms/prod-realm \
| jq -r ".public_key") \
| base64 -d \
| openssl rsa -pubin -text -noout
Public-Key: (2048 bit)
Modulus:
00:f9:b2:ad:ee:4a:10:f6:b7:d9:3b:44:18:bc:b4:
79:96:2b:b8:2a:dc:d7:8a:69:f5:f9:2c:a0:e6:08:
62:1d:99:9c:f4:f4:1a:30:a4:53:1f:27:4d:02:68:
fe:19:de:cf:a3:7f:01:3e:ae:ee:fa:79:8c:84:52:
b3:2d:bb:54:44:09:71:2b:98:5d:db:37:04:bc:46:
da:2b:1e:d8:a0:b2:70:b5:fd:d8:79:60:18:19:80:
a7:d8:20:70:c7:f2:2c:4b:ca:5a:38:2d:83:7c:b4:
9e:b9:ba:5e:f7:4d:b0:6f:f6:55:68:b2:ca:4e:ae:
cb:6a:e4:0e:b1:32:79:f6:7a:c1:7e:e5:52:ee:8f:
f3:0b:a5:d0:78:f4:6d:da:8b:7e:16:55:3d:9a:56:
f7:9e:e5:25:dd:52:7f:d8:7b:d3:34:88:cc:16:19:
3d:1d:cb:fc:c4:43:61:e3:16:76:74:a2:93:3a:a8:
e0:a5:0b:af:c3:27:ad:2d:03:5b:04:79:b8:cf:3c:
25:71:58:a3:76:9b:dd:39:ea:42:28:d1:15:ee:5f:
0e:f8:0b:86:8f:1f:40:e7:82:46:53:60:12:45:8d:
fd:70:fc:bb:48:89:fd:89:50:37:ad:48:a5:4e:f2:
ce:38:47:91:92:ec:69:2a:b5:c4:7c:13:a8:13:f7:
ea:9f
Exponent: 65537 (0x10001)
Google "keycloak well known", and find the rest of the endpoints:
Verify access from the perspective of some application code — something that I’m more familiar with...
import * as jose from "jose";
(async () => {
let accessToken = "{{AccessToken}}"; // from the cookie
let public_key = "{{public_key}}"; // from the realm endpoint
// Convert the plaintext key into a PEM format
const pemKey = `-----BEGIN PUBLIC KEY-----\n${public_key
.match(/.{1,64}/g)!
.join("\n")}\n-----END PUBLIC KEY-----`;
// Verify the JWT
const res = await jose.jwtVerify(
accessToken,
await jose.importSPKI(pemKey, "RS256")
);
})();
If all is good, this should succeed, or throw a JWTExpired
error — the access token
is only valid for 5 minutes, which you can see by subtracting the iat
claim from the exp
claim.
Google how to refresh an access token with keycloak.
Land on https://stackoverflow.com/questions/51386337/refresh-access-token-via-refresh-token-in-keycloak
user@~: $ curl -s -X POST https://console.neon.tech/realms/prod-realm/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=neon-console" \
-d "grant_type=refresh_token" \
-d "refresh_token=${RefreshToken}" \
| jq
- I guessed
client_id
based on the azp
claim from the decoded access token.
grant_type
is refresh_token
based on the StackOverflow post.
refresh_token
is the RefreshToken
from the cookie.
Voilà! Get a fresh access token.
{
"access_token": "<<ACCESS TOKEN>>",
"expires_in": 300,
"refresh_expires_in": 604800,
"refresh_token": "<<REFRESH TOKEN>>",
"token_type": "Bearer",
"id_token": "<<ID TOKEN>>",
"not-before-policy": 0,
"session_state": "<<UUID>>",
"scope": "openid email profile",
"identity_provider_uid": "26389321",
"identity_provider": "github"
}
I refreshed one piece of knowledge, and learned a handful new things — keycloak and an openssl trick.