Skip to content

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 ​

go
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 ​

FunctionIssuer (iss)JWKS URLAudience
AppleConfig(audience string)https://appleid.apple.comhttps://appleid.apple.com/auth/keysSingle: your app's bundle ID or Services ID
GoogleConfig(audiences ...string)https://accounts.google.com and accounts.google.comhttps://www.googleapis.com/oauth2/v3/certsVariadic; 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 ​

go
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:

  1. Parse the JWS; only RS256 is accepted (alg of none, HS256, ES256, etc. all yield ErrInvalidAlgorithm);
  2. Look up the public key in the JWKS by the header's kid (see the next section);
  3. Verify the signature, and validate exp / nbf / iat using the injected clock and ClockSkew;
  4. iss must be in the configured list of issuers;
  5. aud must intersect the configured audiences;
  6. Nonce: when NonceRequired is true, or VerifyWithNonce is called with a non-empty nonce, the token must carry nonce; when an expected value is passed, it must also be equal;
  7. sub must not be empty.

Return value:

go
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:

SentinelMeaning
ErrMalformedTokenNot a valid JWS, or missing sub
ErrInvalidAlgorithmThe signing algorithm isn't RS256
ErrKeyNotFoundFetching JWKS failed (including network errors, caller ctx cancellation or timeout), or the kid still can't be found after a refresh
ErrInvalidSignatureSignature verification failed (and parse errors that don't fit another category)
ErrTokenExpiredexp / nbf / iat validation failed
ErrInvalidIssuerThe issuer isn't in the allowed list
ErrInvalidAudienceNo intersection with the audiences
ErrMissingNonceA nonce is required but the token has none
ErrInvalidNonceThe 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 Verify fetches it synchronously. So NewVerifier succeeds even offline, and network problems show up as ErrKeyNotFound on 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 kid isn'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 is ErrKeyNotFound returned.
  • When the token has no kid, the key is used only if the JWKS contains exactly one key; otherwise ErrKeyNotFound.
  • The cache is bound to the ctx passed to NewVerifier. Pass a context that lives as long as the process and is canceled only at shutdown; don't pass a request-scoped ctx.

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 ​

go
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.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors