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:
- https://www.rfc-editor.org/rfc/rfc7521
- https://www.rfc-editor.org/rfc/rfc7522
- https://oauth.net/
- https://openid.net/specs/openid-connect-core-1_0.html
- https://web.mit.edu/Kerberos/krb5-latest/doc/index.html
- https://www.rfc-editor.org/rfc/rfc6749
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)
}