All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Add signed registration/enrollment handoffs, per-request assurance policy with login-session isolation, and /logout. coulomb-social stays AAL1 unless acr_values or another client raises the bar.
183 lines
4.9 KiB
Go
183 lines
4.9 KiB
Go
package oidc
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// HandoffKind distinguishes registration from MFA-enrollment return envelopes.
|
|
type HandoffKind string
|
|
|
|
const (
|
|
HandoffRegister HandoffKind = "register"
|
|
HandoffEnroll HandoffKind = "enroll"
|
|
)
|
|
|
|
const defaultHandoffTTL = 10 * time.Minute
|
|
|
|
// HandoffEnvelope is the signed, expiring state carried to an allow-listed
|
|
// registration or enrollment URL. Completing a handoff restarts /authorize
|
|
// and never mints a token.
|
|
type HandoffEnvelope struct {
|
|
Kind HandoffKind `json:"kind"`
|
|
ClientID string `json:"client_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
PKCEChallenge string `json:"code_challenge"`
|
|
PKCEChallengeMethod string `json:"code_challenge_method"`
|
|
State string `json:"state"`
|
|
Nonce string `json:"nonce,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
TenantHint string `json:"tenant_hint,omitempty"`
|
|
JTI string `json:"jti"`
|
|
ExpiresAt time.Time `json:"exp"`
|
|
}
|
|
|
|
var (
|
|
errHandoffInvalid = errors.New("invalid handoff")
|
|
errHandoffExpired = errors.New("handoff expired")
|
|
errHandoffReplay = errors.New("handoff replayed")
|
|
)
|
|
|
|
// HandoffStore signs and atomically consumes registration/enrollment envelopes.
|
|
type HandoffStore struct {
|
|
secret []byte
|
|
ttl time.Duration
|
|
|
|
mu sync.Mutex
|
|
consumed map[string]time.Time
|
|
}
|
|
|
|
// NewHandoffStore returns a store with an ephemeral HMAC key. Envelopes are
|
|
// short-lived, so a process restart simply invalidates in-flight handoffs.
|
|
func NewHandoffStore() *HandoffStore {
|
|
secret := make([]byte, 32)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
panic("oidc: failed to generate handoff secret: " + err.Error())
|
|
}
|
|
return &HandoffStore{
|
|
secret: secret,
|
|
ttl: defaultHandoffTTL,
|
|
consumed: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
// Issue signs a new envelope for the given pending authorization.
|
|
func (s *HandoffStore) Issue(kind HandoffKind, ps *PendingState) (string, error) {
|
|
if s == nil {
|
|
return "", errHandoffInvalid
|
|
}
|
|
jti, err := randomID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
env := HandoffEnvelope{
|
|
Kind: kind,
|
|
ClientID: ps.ClientID,
|
|
RedirectURI: ps.RedirectURI,
|
|
PKCEChallenge: ps.PKCEChallenge,
|
|
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
|
State: ps.State,
|
|
Nonce: ps.Nonce,
|
|
Scopes: append([]string(nil), ps.Scopes...),
|
|
TenantHint: ps.TenantHint,
|
|
JTI: jti,
|
|
ExpiresAt: time.Now().Add(s.ttl),
|
|
}
|
|
payload, err := json.Marshal(env)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
mac.Write(payload)
|
|
token := base64.RawURLEncoding.EncodeToString(payload) + "." +
|
|
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
return token, nil
|
|
}
|
|
|
|
// Consume verifies the envelope, rejects expiry/tamper/replay, and marks the
|
|
// JTI used. The caller must restart /authorize; it must not mint a token.
|
|
func (s *HandoffStore) Consume(token string) (*HandoffEnvelope, error) {
|
|
if s == nil {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
payload, err := s.verify(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var env HandoffEnvelope
|
|
if err := json.Unmarshal(payload, &env); err != nil {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
if env.JTI == "" || env.ClientID == "" || env.RedirectURI == "" {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
if time.Now().After(env.ExpiresAt) {
|
|
return nil, errHandoffExpired
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.gcLocked()
|
|
if _, used := s.consumed[env.JTI]; used {
|
|
return nil, errHandoffReplay
|
|
}
|
|
s.consumed[env.JTI] = env.ExpiresAt
|
|
return &env, nil
|
|
}
|
|
|
|
func (s *HandoffStore) verify(token string) ([]byte, error) {
|
|
dot := strings.LastIndex(token, ".")
|
|
if dot <= 0 || dot == len(token)-1 {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(token[:dot])
|
|
if err != nil {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
sig, err := base64.RawURLEncoding.DecodeString(token[dot+1:])
|
|
if err != nil {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
mac.Write(payload)
|
|
if !hmac.Equal(mac.Sum(nil), sig) {
|
|
return nil, errHandoffInvalid
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *HandoffStore) gcLocked() {
|
|
now := time.Now()
|
|
for jti, exp := range s.consumed {
|
|
if now.After(exp) {
|
|
delete(s.consumed, jti)
|
|
}
|
|
}
|
|
}
|
|
|
|
func randomID() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func appendHandoff(destURL, token string) (string, error) {
|
|
u, err := url.Parse(destURL)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return "", errHandoffInvalid
|
|
}
|
|
q := u.Query()
|
|
q.Set("kc_handoff", token)
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|