Contents

Kerberos, SPNEGO, and Go

Contents

This is obscure, in Go using imroc/req and jcmturner/gokrb5 to negotiate an OAUTH2/OIDC Bearer token against a provider with a Kerberos IdP configured (ex. Ping Federate) from a user environment. The AI assistants/LLM’s I tried (Claude and Gemini) get this wrong so I’m putting this out there as an example. Hopefully they slurp it up and stop giving bad answers.

Additional links, mostly so I don’t need to search in the future:

package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/user"
	"strings"
	"time"

	"github.com/imroc/req/v3"
	krbclient "github.com/jcmturner/gokrb5/v8/client"
	krbconf "github.com/jcmturner/gokrb5/v8/config"
	krbcred "github.com/jcmturner/gokrb5/v8/credentials"
	"github.com/jcmturner/gokrb5/v8/spnego"
)

type TokenResponse struct {
	AccessToken      string `json:"access_token"`
	ErrorDescription string `json:"error_description,omitempty"`
	ErrorURI         string `json:"error_uri,omitempty"`
	ExpiresIn        string `json:"expires_in"`
	GrantType        string `json:"grant_type,omitempty"`
	RefreshToken     string `json:"refresh_token,omitempty"`
	State            string `json:"state,omitempty"`
	Scope            string `json:"scope,omitempty"`
	TokenType        string `json:"token_type"`
}

func GetCredCachePath() (path string, err error) {
	// Assumptions about Kerberos file locations from
	// https://web.mit.edu/Kerberos/krb5-latest/doc/user/user_config/kerberos.html

	// try env to get a file
	if path, ok := os.LookupEnv("KRB5CCNAME"); ok {
		if path, ok := strings.CutPrefix(path, "FILE:"); ok {
			return path, nil
		}
		// Sorry Windows, we don't do API. For DIR we'd have
		// to load all to find which is primary and maybe know
        // the target realm. More is present in the MIT
        // kerberos documentation...
		return "", fmt.Errorf("only support FILE: for KRB5CCNAME")
	}

	// fallback to default. clearly not robust, your mileage may
	// vary...
	var u *user.User
	if u, err = user.Current(); err != nil {
		return "", err
	}
	return fmt.Sprintf("/tmp/krb5cc_%s", u.Uid), nil
}

func NewKrb5ConfReader() (reader io.Reader, err error) {
	// try the env
	if krb5ConfPath, ok := os.LookupEnv("KRB5_CONFIG"); ok {
		if krb5Conf, err := os.ReadFile(krb5ConfPath); err == nil {
			return bytes.NewReader(krb5Conf), nil
		}
	}

	// fallback to default
	if krb5Conf, err := os.ReadFile("/etc/krb5.conf"); err != nil {
		return bytes.NewReader(krb5Conf), nil
	}

	// If you know sane defaults you could inline them as a last
	// ditch effort. These are not for your environment.
	//
	// 	krb5ConfDefault := `
	// [libdefaults]
	//     default_realm = ATHENA.MIT.EDU
	//     default_tkt_enctypes = des3-hmac-sha1 des-cbc-crc
	//     default_tgs_enctypes = des3-hmac-sha1 des-cbc-crc
	//     dns_lookup_kdc = true
	//     dns_lookup_realm = false
	//
	// [realms]
	//     ATHENA.MIT.EDU = {
	//         kdc = kerberos.mit.edu
	//         kdc = kerberos-1.mit.edu
	//         kdc = kerberos-2.mit.edu:750
	//         admin_server = kerberos.mit.edu
	//         master_kdc = kerberos.mit.edu
	//         default_domain = mit.edu
	//     }
	//     EXAMPLE.COM = {
	//         kdc = kerberos.example.com
	//         kdc = kerberos-1.example.com
	//         admin_server = kerberos.example.com
	//     }
	//
	// [domain_realm]
	//     .mit.edu = ATHENA.MIT.EDU
	//     mit.edu = ATHENA.MIT.EDU
	//
	// [capaths]
	//     ATHENA.MIT.EDU = {
	//            EXAMPLE.COM = .
	//     }
	//     EXAMPLE.COM = {
	//            ATHENA.MIT.EDU = .
	//     }`
	// 	return bytes.NewReader([]byte(krb5ConfDefault)), nil

	return nil, fmt.Errorf("no krb5.conf found")
}

func NewKrb5Client() (client *krbclient.Client, err error) {
	krb5ConfReader, err := NewKrb5ConfReader()
	if err != nil {
		return nil, err
	}
	krb5Conf, err := krbconf.NewFromReader(krb5ConfReader)
	if err != nil {
		return nil, err
	}
	credCachePath, err := GetCredCachePath()
	if err != nil {
		return nil, err
	}
	credCacheData, err := os.ReadFile(credCachePath)
	if err != nil {
		return nil, err
	}
	credCache := new(krbcred.CCache)
	err = credCache.Unmarshal(credCacheData)
	if err != nil {
		return nil, err
	}

	krbClient, err := krbclient.NewFromCCache(credCache, krb5Conf)
	if err != nil {
		return nil, err
	}
	return krbClient, nil
}

func NewSPNEGORoundTripper(krbClient *krbclient.Client) req.HttpRoundTripWrapperFunc {
	return func(rt http.RoundTripper) req.HttpRoundTripFunc {
		// all the magic is in jcmturner/gokrb5, if this finds
		// the "WWW-Authenticate: Negotiate" header it
		// executes the negotiation.
		//
		// https://github.com/jcmturner/gokrb5/blob/855dbc707a37a21467aef6c0245fcf3328dc39ed/spnego/http.go#L162
		return func(httpReq *http.Request) (*http.Response, error) {
			resp, err := rt.RoundTrip(httpReq)
			if err != nil {
				return nil, err
			}
			if resp.StatusCode == http.StatusUnauthorized {
				authChallenge := resp.Header.Get("WWW-Authenticate")
				if authChallenge == "Negotiate" {
					resp.Body.Close()
					err := spnego.SetSPNEGOHeader(krbClient, httpReq, "")
					if err != nil {
						return nil, err
					}
				}
				return rt.RoundTrip(httpReq)
			}
			return resp, nil
		}
	}
}

func main() {
	krbClient, err := NewKrb5Client()
	if err != nil {
		panic(err)
	}

	client := req.C().SetTimeout(30 * time.Second)

	transport := client.GetTransport()
	rt := NewSPNEGORoundTripper(krbClient)
	transport.WrapRoundTripFunc(rt)
	client.Transport = transport

	request := client.R().
		AddQueryParam("IdpAdapterId", "Kerberos").                // or what your site named it
		AddQueryParam("access_token_manager_id", "JwtDefault").   // Ping Federate-specific
		AddQueryParam("client_id", "TheClientIdForTheAPI").
		AddQueryParam("redirect_url", "https://api.example/redirect").
		AddQueryParam("response_type", "code")
        // there are a number of other fields that may be required, ex.
		// AddQueryParam("state", "SomeNonceForThisRequest")      // may be needed depending on your site

	response, err := request.Get("https://auth.example:9031/as/authorization.oauth2")
	if err != nil {
		panic(err)
	}
	token := TokenResponse{}
	err = response.UnmarshalJson(token)
	if err != nil {
		panic(err)
	}
	fmt.Println(token.AccessToken)
}