Files
summercms/modules/wristband/oidc_keys.go
Jakub Zych 659b42aaa8 feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer
- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
  Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
  resume and one-shot consent token, token with an RS256 id_token signed
  through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
2026-10-19 18:18:05 +02:00

239 lines
7.3 KiB
Go

package wristband
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"time"
"github.com/golang-jwt/jwt/v5"
)
// The id_token signing algorithms (D-12).
const (
// AlgRS256 is RSASSA-PKCS1-v1_5 with SHA-256 over an RSA key, the
// default and the algorithm of imported PHP keys.
AlgRS256 = "RS256"
// AlgES256 is ECDSA over P-256 with SHA-256.
AlgES256 = "ES256"
)
// SigningKey is the active signing key. Signer must be the
// *rsa.PrivateKey or *ecdsa.PrivateKey matching Alg, as returned by
// ParsePrivateKeyPEM. Key material reaches the engine only here and is
// never logged (T-19.4-09).
type SigningKey struct {
Kid string
Alg string
Signer crypto.Signer
}
// PublicKey is a published verification key.
type PublicKey struct {
Kid string
Alg string
Key crypto.PublicKey
}
// KeyStore supplies the signing-key ring (D-10..D-13).
type KeyStore interface {
// ActiveSigningKey returns the active key, generating the first key
// when the ring is empty.
ActiveSigningKey(ctx context.Context) (SigningKey, error)
// PublishedKeys returns the active key plus retired keys still inside
// their grace window at now, for JWKS and discovery.
PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error)
}
// GenerateSigningKey creates a new key for alg: RSA 2048 for RS256. It
// returns a kid of 8 random bytes in lowercase hex (16 characters, like the
// PHP key ring), the private key as PKCS#8 PEM and the public key as PKIX
// PEM.
func GenerateSigningKey(alg string) (kid string, privatePEM, publicPEM []byte, err error) {
var priv crypto.Signer
switch alg {
case AlgRS256:
priv, err = rsa.GenerateKey(rand.Reader, 2048)
default:
return "", nil, nil, fmt.Errorf("wristband: unsupported signing algorithm %q", alg)
}
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: generate signing key: %w", err)
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: encode private key: %w", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
return "", nil, nil, fmt.Errorf("wristband: encode public key: %w", err)
}
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", nil, nil, fmt.Errorf("wristband: generate kid: %w", err)
}
privatePEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
publicPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
return hex.EncodeToString(raw), privatePEM, publicPEM, nil
}
// ParsePrivateKeyPEM parses a PKCS#8 private key.
func ParsePrivateKeyPEM(data []byte) (crypto.Signer, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("wristband: no PEM block in private key")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("wristband: unreadable private key")
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("wristband: unsupported private key type")
}
if _, ok := signer.(*rsa.PrivateKey); !ok {
return nil, errors.New("wristband: unsupported private key type")
}
return signer, nil
}
// ParsePublicKeyPEM parses a PKIX public key.
func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("wristband: no PEM block in public key")
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, errors.New("wristband: unreadable public key")
}
switch key.(type) {
case *rsa.PublicKey:
return key, nil
}
return nil, errors.New("wristband: unsupported public key type")
}
// jwkDocument is one JWKS entry. encoding/json keeps declaration order and
// omitempty drops the members of the other key type, so RSA keys serialise
// as kty, use, alg, kid, n, e and EC keys as kty, use, alg, kid, crv, x, y.
type jwkDocument struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
Crv string `json:"crv,omitempty"`
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
}
// jwkFor encodes a published key with the standard library (RFC 7518
// §6.3.1: n and e are minimal big-endian bytes). Every JWK carries use sig
// and its alg; an Alg that does not match the key type is refused
// (T-19.4-08).
func jwkFor(k PublicKey) (jwkDocument, error) {
b64 := base64.RawURLEncoding.EncodeToString
switch key := k.Key.(type) {
case *rsa.PublicKey:
if k.Alg != AlgRS256 {
return jwkDocument{}, errors.New("wristband: RSA key with a non-RSA algorithm")
}
return jwkDocument{
Kty: "RSA", Use: "sig", Alg: k.Alg, Kid: k.Kid,
N: b64(key.N.Bytes()), E: b64(big.NewInt(int64(key.E)).Bytes()),
}, nil
}
return jwkDocument{}, errors.New("wristband: unsupported public key type")
}
// idTokenInput carries the claims of one id_token (PHP TokenIssuer).
type idTokenInput struct {
Issuer string
Subject string
Audience string
TTL time.Duration
Now time.Time
Nonce string
AccessToken string
Identity map[string]any
Allowed []string
}
// protocolClaims are the claims the engine owns; an identity claim with one
// of these names is never copied (T-19.4-11).
var protocolClaims = map[string]bool{
"iss": true, "sub": true, "aud": true, "exp": true, "iat": true,
"nonce": true, "at_hash": true, "auth_time": true, "azp": true, "nbf": true, "jti": true,
}
// signIDToken signs an id_token with golang-jwt map claims, so aud stays a
// JSON string (the package global MarshalSingleStringAsArray only affects
// jwt.ClaimStrings and is never touched). Allowlisted identity claims are
// written first, then the protocol claims, then sub once more, so a user
// attribute can never overwrite a protocol claim (T-19.4-11).
func signIDToken(key SigningKey, in idTokenInput) (string, error) {
method, err := signingMethodFor(key)
if err != nil {
return "", err
}
claims := jwt.MapClaims{}
for _, name := range in.Allowed {
if protocolClaims[name] {
continue
}
if v, ok := in.Identity[name]; ok {
claims[name] = v
}
}
claims["iss"] = in.Issuer
claims["sub"] = in.Subject
claims["aud"] = in.Audience
claims["exp"] = in.Now.Add(in.TTL).Unix()
claims["iat"] = in.Now.Unix()
if in.Nonce != "" {
claims["nonce"] = in.Nonce
}
if in.AccessToken != "" {
claims["at_hash"] = atHash(in.AccessToken)
}
claims["sub"] = in.Subject
tok := jwt.NewWithClaims(method, claims)
tok.Header["kid"] = key.Kid
signed, err := tok.SignedString(key.Signer)
if err != nil {
return "", errors.New("wristband: id_token signing failed")
}
return signed, nil
}
// signingMethodFor derives the JWS method from the key type and refuses a
// key whose Alg does not match it (T-19.4-08).
func signingMethodFor(key SigningKey) (jwt.SigningMethod, error) {
switch key.Alg {
case AlgRS256:
if _, ok := key.Signer.(*rsa.PrivateKey); ok {
return jwt.SigningMethodRS256, nil
}
}
return nil, errors.New("wristband: signing key does not match its algorithm")
}
// atHash is base64url of the left half of SHA-256 over the access token
// (OIDC Core §3.1.3.6; RS256 and ES256 both hash with SHA-256).
func atHash(accessToken string) string {
sum := sha256.Sum256([]byte(accessToken))
return base64.RawURLEncoding.EncodeToString(sum[:16])
}