Files
konradandCursor f082561cc6 Mint and verify short-lived RS256 user tokens.
ExApps sign after AppAPI auth; Microservices check a static public key or an OIDC issuer and forward the same bearer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:03:52 +02:00

125 lines
3.0 KiB
Go

package usertoken
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
type oidcVerifier struct {
verifier *oidc.IDTokenVerifier
groupsClaim string
skew time.Duration
}
func newOIDCVerifier(ctx context.Context, issuer, audience, groupsClaim string, skew time.Duration) (tokenVerifier, error) {
issuer = strings.TrimSpace(issuer)
if issuer == "" {
return nil, fmt.Errorf("OIDC issuer is empty")
}
if skew <= 0 {
skew = defaultSkew
}
groupsClaim = strings.TrimSpace(groupsClaim)
if groupsClaim == "" {
groupsClaim = "groups"
}
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("oidc discovery: %w", err)
}
aud := strings.TrimSpace(audience)
verifier := provider.Verifier(&oidc.Config{
ClientID: aud,
SkipClientIDCheck: aud == "",
SupportedSigningAlgs: []string{"RS256"},
Now: func() time.Time {
return time.Now().Add(-skew)
},
})
return &oidcVerifier{verifier: verifier, groupsClaim: groupsClaim, skew: skew}, nil
}
func (v *oidcVerifier) verify(ctx context.Context, raw string) (Caller, error) {
if strings.TrimSpace(raw) == "" {
return Caller{}, unauthorized("empty token")
}
tok, err := v.verifier.Verify(ctx, raw)
if err != nil {
return Caller{}, fmt.Errorf("oidc verify: %w", errors.Join(err, ErrUnauthorized))
}
var claims map[string]any
if err := tok.Claims(&claims); err != nil {
return Caller{}, unauthorized("claims")
}
now := time.Now()
if err := checkTimeClaim(claims, "iat", now, v.skew); err != nil {
return Caller{}, err
}
if err := checkTimeClaim(claims, "nbf", now, v.skew); err != nil {
return Caller{}, err
}
sub, _ := claims["sub"].(string)
username, _ := claims["preferred_username"].(string)
groups, err := groupsFromMap(claims, v.groupsClaim)
if err != nil {
return Caller{}, err
}
return callerFromClaims(sub, username, groups)
}
// checkTimeClaim rejects a numeric claim that is after now+skew.
// A missing claim is fine.
func checkTimeClaim(claims map[string]any, name string, now time.Time, skew time.Duration) error {
raw, ok := claims[name]
if !ok || raw == nil {
return nil
}
sec, ok := numericUnix(raw)
if !ok {
return unauthorized("bad " + name)
}
when := time.Unix(sec, 0)
if when.After(now.Add(skew)) {
return unauthorized(name + " in the future")
}
return nil
}
func numericUnix(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
default:
return 0, false
}
}
func groupsFromMap(claims map[string]any, name string) (*[]string, error) {
raw, ok := claims[name]
if !ok || raw == nil {
return nil, nil
}
arr, ok := raw.([]any)
if !ok {
return nil, unauthorized("groups claim")
}
out := make([]string, 0, len(arr))
for _, item := range arr {
s, ok := item.(string)
if !ok {
return nil, unauthorized("groups claim")
}
out = append(out, s)
}
return &out, nil
}