oidcverify
github.com/gonewx/wicket/oidcverify verifies ID tokens locally using the JWKS public keys published by the provider, and returns the user identifier in them. It's meant for the scenario where "the app has completed Apple / Google sign-in and sends the ID token to the backend".
Quick example
import (
"context"
"errors"
"github.com/gonewx/wicket/oidcverify"
"github.com/gonewx/wicket/services/impl"
)
// Construct once at startup and reuse within the process; the JWKS cache stops when appCtx is canceled
verifier, err := oidcverify.NewVerifier(appCtx, oidcverify.AppleConfig("com.example.app"), impl.NewSystemClock())
if err != nil {
return err // only configuration errors such as an empty issuer or audience end up here
}
// On each login request
tok, err := verifier.VerifyWithNonce(r.Context(), idToken, expectedNonce)
switch {
case err == nil:
user, err := findOrCreateUser("apple", tok.Subject, tok.Email, tok.EmailVerified)
// ...
case errors.Is(err, oidcverify.ErrTokenExpired):
// ask the client to sign in again
case errors.Is(err, oidcverify.ErrKeyNotFound):
// possibly a JWKS fetch failure (network, timeout), not necessarily the user's fault: log and alert
default:
// treat as 401
}The above is a snippet embedded in application code (illustrative; appCtx, findOrCreateUser, etc. are your own code). The complete example at the end of the page starts a local JWKS server and issues test tokens; it has been compiled and run, with output shown in comments.
Provider presets
| Function | Issuer (iss) | JWKS URL | Audience |
|---|---|---|---|
AppleConfig(audience string) | https://appleid.apple.com | https://appleid.apple.com/auth/keys | Single: your app's bundle ID or Services ID |
GoogleConfig(audiences ...string) | https://accounts.google.com and accounts.google.com | https://www.googleapis.com/oauth2/v3/certs | Variadic; can accept multiple client IDs for Android, iOS, and Web at once |
Both presets have a ClockSkew of 5 minutes. Google accepts the issuer both with and without https://, because both forms have appeared in its ID tokens. The issuers and JWKS URLs are also exported as constants: AppleIssuer, AppleJWKSURI, GoogleIssuerHTTPS, GoogleIssuerPlain, GoogleJWKSURI.
Don't forget the audience
Calling GoogleConfig() with no arguments leaves the audience empty, and NewVerifier returns oidcverify: at least one audience must be configured. Audience validation is what prevents "a token obtained by another app being used to sign in to your app", so it can't be skipped.
Custom providers: ProviderConfig
type ProviderConfig struct {
Issuer string // single issuer
Issuers []string // multiple issuers; passes if any is equal; takes precedence over Issuer when non-empty
JWKSURI string // JWKS URL
Audience string // single audience
Audiences []string // multiple audiences; passes if it intersects the token's aud; takes precedence over Audience when non-empty
ClockSkew time.Duration // time tolerance; 5 minutes when 0
NonceRequired bool // when true, the token must carry a nonce
}Use either the singular or the plural field; when both are set, the plural field wins and the singular one is ignored. Issuer comparison is exact equality, with no prefix or case normalization.
What verification does
Verify(ctx, idToken) and VerifyWithNonce(ctx, idToken, nonce) check the following in order, returning the corresponding sentinel error as soon as a step fails:
- Parse the JWS; only RS256 is accepted (
algofnone,HS256,ES256, etc. all yieldErrInvalidAlgorithm); - Look up the public key in the JWKS by the header's
kid(see the next section); - Verify the signature, and validate
exp/nbf/iatusing the injected clock andClockSkew; issmust be in the configured list of issuers;audmust intersect the configured audiences;- Nonce: when
NonceRequiredis true, orVerifyWithNonceis called with a non-empty nonce, the token must carrynonce; when an expected value is passed, it must also be equal; submust not be empty.
Return value:
type VerifiedToken struct {
Subject string // sub, the user's unique identifier at this provider
Email string // email, may be empty
EmailVerified bool // email_verified, accepts both a boolean and the string "true"
Issuer string
Audience []string
}Only these fields are projected; parse other claims yourself if you need them. Use "issuer + Subject" as the user's primary key, not the email: emails can change, can be hidden, and may be unverified.
Errors
All are package-level sentinels; check them with errors.Is:
| Sentinel | Meaning |
|---|---|
ErrMalformedToken | Not a valid JWS, or missing sub |
ErrInvalidAlgorithm | The signing algorithm isn't RS256 |
ErrKeyNotFound | Fetching JWKS failed (including network errors, caller ctx cancellation or timeout), or the kid still can't be found after a refresh |
ErrInvalidSignature | Signature verification failed (and parse errors that don't fit another category) |
ErrTokenExpired | exp / nbf / iat validation failed |
ErrInvalidIssuer | The issuer isn't in the allowed list |
ErrInvalidAudience | No intersection with the audiences |
ErrMissingNonce | A nonce is required but the token has none |
ErrInvalidNonce | The nonce doesn't match the expected value |
Errors from NewVerifier itself aren't any of these sentinels; they're plain configuration errors.
JWKS caching
- Construction only registers the JWKS URL and makes no network request; the first
Verifyfetches it synchronously. SoNewVerifiersucceeds even offline, and network problems show up asErrKeyNotFoundon the first verification. - After fetching, the keys are managed by jwx's
jwk.Cache, with a minimum refresh interval of 15 minutes. - When the token's
kidisn't found in the cache, a refresh is forced immediately (to handle provider key rotation); only if it's still not found after the refresh isErrKeyNotFoundreturned. - When the token has no
kid, the key is used only if the JWKS contains exactly one key; otherwiseErrKeyNotFound. - The cache is bound to the
ctxpassed toNewVerifier. Pass a context that lives as long as the process and is canceled only at shutdown; don't pass a request-scopedctx.
Once the construction ctx is canceled, the verifier is unusable
Observed on v0.1.4: once the construction ctx is canceled, every subsequent Verify fails—even if the public keys had been cached successfully before—and the call blocks until the call's own ctx ends before returning ErrKeyNotFound. If the call's ctx has no deadline, the call blocks forever. So: cancel the construction ctx only when the process exits, and pass a request ctx with a deadline to every Verify.
Clock
The third parameter is a services.SystemClock (with a single method, Now() time.Time). Use impl.NewSystemClock() in production; in tests you can inject a fixed-time clock to make expiry checks repeatable.
Complete runnable example
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"time"
"github.com/gonewx/wicket/oidcverify"
"github.com/gonewx/wicket/services/impl"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
// Simulate a provider: generate an RS256 key and publish the JWKS over HTTP
raw, _ := rsa.GenerateKey(rand.Reader, 2048)
priv, _ := jwk.Import(raw)
_ = priv.Set(jwk.KeyIDKey, "k1")
_ = priv.Set(jwk.AlgorithmKey, jwa.RS256())
pub, _ := priv.PublicKey()
set := jwk.NewSet()
_ = set.AddKey(pub)
jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(set)
}))
defer jwks.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
v, err := oidcverify.NewVerifier(ctx, oidcverify.ProviderConfig{
Issuer: "https://idp.example.com",
JWKSURI: jwks.URL,
Audiences: []string{"mobile-app", "web-app"},
ClockSkew: 2 * time.Minute,
}, impl.NewSystemClock())
if err != nil {
log.Fatal(err)
}
sign := func(iss, aud, nonce string, ttl time.Duration) string {
t := jwt.New()
_ = t.Set(jwt.IssuerKey, iss)
_ = t.Set(jwt.AudienceKey, aud)
_ = t.Set(jwt.SubjectKey, "user-1")
_ = t.Set(jwt.ExpirationKey, time.Now().Add(ttl))
_ = t.Set("email", "u@example.com")
_ = t.Set("email_verified", "true")
if nonce != "" {
_ = t.Set("nonce", nonce)
}
b, _ := jwt.Sign(t, jwt.WithKey(jwa.RS256(), priv))
return string(b)
}
tok, err := v.Verify(ctx, sign("https://idp.example.com", "web-app", "", time.Hour))
fmt.Printf("%+v %v\n", tok, err)
// &{Subject:user-1 Email:u@example.com EmailVerified:true Issuer:https://idp.example.com Audience:[web-app]} <nil>
_, err = v.VerifyWithNonce(ctx, sign("https://idp.example.com", "web-app", "n-1", time.Hour), "n-2")
fmt.Println(errors.Is(err, oidcverify.ErrInvalidNonce)) // true
_, err = v.Verify(ctx, sign("https://evil.example.com", "web-app", "", time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrInvalidIssuer)) // true
_, err = v.Verify(ctx, sign("https://idp.example.com", "other", "", time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrInvalidAudience)) // true
_, err = v.Verify(ctx, sign("https://idp.example.com", "web-app", "", -time.Hour))
fmt.Println(errors.Is(err, oidcverify.ErrTokenExpired)) // true
}Relationship to the container
The container registers an empty *oidcverify.ProviderConfig singleton (root.Ptr_ProviderConfig()), but doesn't register a Verifier, and Wicket itself doesn't read it. Just construct the verifier directly in your initialization code; there's no need to go through the container.