jwx: Cannot verify Azure oauth2 token signature of a valid token.
Describe the bug Cannot verify Azure oauth2 token signature, while token is definitely good and valid - allows me to access Microsoft Graph.
The error that shows up while I want to validate the token:
failed to parse payload: failed to verify jws signature: failed to verify message: crypto/rsa: verification error
Pretty sure I must be doing something wrong, but I cannot identify what it is.
go version:
go version go1.16.4 darwin/amd64
To Reproduce / Expected behavior I’m attaching a full code that uses an existing app registration, assigns a device code, created a cached refresh token, and requests a new token:
main.go
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/public"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
)
var cacheAccessor = &TokenCache{"serialized_cache.json"}
func acquireTokenDeviceCode() string {
config := CreateConfig("config.json")
app, err := public.New(config.ClientID, public.WithCache(cacheAccessor), public.WithAuthority(config.Authority))
if err != nil {
panic(err)
}
// look in the cache to see if the account to use has been cached
var userAccount public.Account
accounts := app.Accounts()
for _, account := range accounts {
if strings.EqualFold(account.PreferredUsername, config.Username) {
userAccount = account
}
}
// found a cached account, now see if an applicable token has been cached
// NOTE: this API conflates error states, i.e. err is non-nil if an applicable token isn't
// cached or if something goes wrong (making the HTTP request, unmarshalling, etc).
authResult, err := app.AcquireTokenSilent(context.Background(), config.Scopes, public.WithSilentAccount(userAccount))
if err != nil {
// either there was no cached account/token or the call to AcquireTokenSilent() failed
// make a new request to AAD
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
defer cancel()
devCode, err := app.AcquireTokenByDeviceCode(ctx, config.Scopes)
if err != nil {
panic(err)
}
fmt.Printf("Device Code is: %s\n", devCode.Result.Message)
result, err := devCode.AuthenticationResult(ctx)
if err != nil {
panic(fmt.Sprintf("got error while waiting for user to input the device code: %s", err))
}
fmt.Println("Access token is " + result.AccessToken)
fmt.Printf("Granted scopes are %v\n", authResult.GrantedScopes)
return authResult.AccessToken
}
fmt.Println("Access token is " + authResult.AccessToken)
fmt.Printf("Granted scopes are %v\n", authResult.GrantedScopes)
return authResult.AccessToken
}
func callMicrosoftGraph(token string) {
url := "https://graph.microsoft.com/v1.0/me"
// Create a Bearer string by appending string access token
var bearer = "Bearer " + token
// Create a new request using http
req, err := http.NewRequest("GET", url, nil)
if err != nil {
panic(err)
}
// add authorization header to the req
req.Header.Add("Authorization", bearer)
// Send req using http Client
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Println("Error on response.\n[ERROR] -", err)
}
defer resp.Body.Close()
// read into string
bytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Error while reading the response bytes:", err)
}
fmt.Println(string(bytes))
}
func getJWTkeyset() (jwk.Set, error) {
// TODO get this url from https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
const jwksURL = `https://login.microsoftonline.com/common/discovery/v2.0/keys`
ctx := context.TODO()
ar := jwk.NewAutoRefresh(ctx)
// Tell *jwk.AutoRefresh that we only want to refresh this JWKS
// when it needs to (based on Cache-Control or Expires header from
// the HTTP response). If the calculated minimum refresh interval is less
// than 15 minutes, don't go refreshing any earlier than 15 minutes.
ar.Configure(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute))
// Refresh the JWKS once before getting into the main loop.
// This allows you to check if the JWKS is available before we start
// a long-running program
_, err := ar.Refresh(ctx, jwksURL)
if err != nil {
fmt.Printf("failed to refresh JWKS: %s\n", err)
return nil, err
}
keyset, err := ar.Fetch(ctx, jwksURL)
if err != nil {
fmt.Printf("failed to fetch JWKS: %s\n", err)
return nil, err
}
return keyset, nil
}
func validateToken(tokenString string) bool {
log.Println("Validating token")
keyset, err := getJWTkeyset()
if err != nil {
panic(err)
}
// Actual verification:
// FINALLY. This is how you Parse and verify the payload.
// Key IDs are automatically matched.
// There was a lot of code above, but as a consumer, below is really all you need
// to write in your code
token, err := jwt.ParseString(
tokenString,
// Tell the parser that you want to use this keyset
// this will validate signature
// TODO this does not work! why?
jwt.WithKeySet(keyset),
// validate expiration etc.
jwt.WithValidate(true),
)
_ = keyset
if err != nil {
fmt.Printf("failed to parse payload: %s\n", err)
return false
}
fmt.Printf("parsed token %v\n", token)
tid, _ := token.Get("tid")
fmt.Printf("parsed tid %v\n", tid)
return true
}
func main() {
token := acquireTokenDeviceCode()
valid := validateToken(token)
log.Printf("valid token: %t", valid)
callMicrosoftGraph(token)
}
cache_accessor.go:
package main
import (
"io/ioutil"
"log"
"os"
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/cache"
)
type TokenCache struct {
file string
}
func (t *TokenCache) Replace(cache cache.Unmarshaler, key string) {
jsonFile, err := os.Open(t.file)
if err != nil {
log.Println(err)
}
defer jsonFile.Close()
data, err := ioutil.ReadAll(jsonFile)
if err != nil {
log.Println(err)
}
err = cache.Unmarshal(data)
if err != nil {
log.Println(err)
}
}
func (t *TokenCache) Export(cache cache.Marshaler, key string) {
data, err := cache.Marshal()
if err != nil {
log.Println(err)
}
err = ioutil.WriteFile(t.file, data, 0600)
if err != nil {
log.Println(err)
}
}
utils.go
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
package main
import (
"encoding/json"
"io/ioutil"
"log"
"os"
)
// Config represents the config.json required to run the samples
type Config struct {
ClientID string `json:"client_id"`
Authority string `json:"authority"`
Scopes []string `json:"scopes"`
Username string `json:"username"`
}
// CreateConfig creates the Config struct from a json file.
func CreateConfig(fileName string) *Config {
jsonFile, err := os.Open(fileName)
if err != nil {
log.Fatal(err)
}
defer jsonFile.Close()
data, err := ioutil.ReadAll(jsonFile)
if err != nil {
log.Fatal(err)
}
config := &Config{}
err = json.Unmarshal(data, config)
if err != nil {
log.Fatal(err)
}
return config
}
config.json (anonymized):
{
"authority": "https://login.microsoftonline.com/<TENANTID>",
"client_id": "...",
"scopes": [
"user.read"
],
"username": "..."
}
Additional context I’m using version 1.1.7 of the library because of #380 (my last comment)
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Comments: 20
Reg. ParseHeader - in this example I did not use it, true, but I did in other tests in the meantime.
However the puzzle is solved already, and of course nothing is wrong with the library 😉
TLDR; Azure provided 2 different kinds of tokens. One is AccessToken (the one I used), the other one is IDToken. The former one is not exactly (although VERY similar) an JWT token. Signature is different, and requires special handling during verification. The latter one is a standard JWT token and can be successfully verified using standard methods. So, in the code above, the solution was to change
authResult.AccessToken
toauthResult.IDToken.RawToken
and all works flawlessly. Note that the above-mentioned workaround for the key algorithms is required, nothing changes in that regard.More info here:
Hmm, bummer. Maybe we “fixed”
WithVerify
where we shouldn’t have. Will take a lookhttps://github.com/lestrrat-go/jwx/issues/380#issuecomment-860754799
Instead of answering in multiple places, I’m just going to answer here. I believe this API is okay, because
WithKeySet
forces you to use a key with thealg
header,WithVerify
(…a misnomer, but anyway) allows you to pass whatever key you want as long as you pass the algorithm name with it.WithKeySet
, you can always fetch the key, and modify it before passing toWithKeySet
The rationale for requiring
alg
is security as you mentioned. But the important distinction from the library’s PoV is that we don’t provide automatic ways to shoot yourself in the foot: i.e. we’re not going to take a key set and automatically assume that both the JWS payload and JWK are well-intentioned players who will not sneakalg: "none"
or open up the possibility of cheating against potentially weak keys. SoWithKeySet
, which automatically selects a key to use, is not going to assume that checkingalg
can be skipped.On the other hand,
WithVerify
which takes a single key, is something that the user manually specifies, and therefore we allow the users to shoot themselves in the foot. If you want to fabricate thealg
value or whatever, you are free to do as you wish.On this note I think that a) The RFC is flawed for allowing this sort of implementation specific loophole/discrepancy, and b) Microsoft should know better to include
alg
– I mean, it’s not going to cause a problem for anybody if thealg
header is present, but causes problems like this if it isn’t. They should just add it 😃So, what can you do on your end?
First, let me state that I haven’t worked with Azure much, so there may be limitation and workarounds that I’m not aware of. Following comes from many assumptions.
So, assuming that Microsoft will not be adding
alg
anytime soon, and that while the keys may be rotated frequently but that their algorithm will stay the same, I would opt to find out the the algorithm used by each of their keys, and hardcode them somewhere either:or