golib/auth/jwt/types.go

161 lines
5.1 KiB
Go

// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// SPDX-License-Identifier: MPL-2.0
package jwt
import (
"encoding/json"
"fmt"
"strings"
)
// JOSE "typ" header values. The signer sets [DefaultTokenTyp] automatically.
// Use [NewAccessToken] or [JWT.SetTyp] to produce an OAuth 2.1 access token
// with [AccessTokenTyp].
const (
DefaultTokenTyp = "JWT" // standard JWT
AccessTokenTyp = "at+jwt" // OAuth 2.1 access token (RFC 9068 §2.1)
)
// Listish handles the JWT "aud" claim quirk: RFC 7519 §4.1.3 allows
// it to be either a single string or an array of strings.
//
// It unmarshals from both a single string ("https://auth.example.com") and
// an array (["https://api.example.com", "https://app.example.com"]).
// It marshals to a plain string for a single value and to an array for
// multiple values.
//
// https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3
type Listish []string
// UnmarshalJSON decodes both the string and []string forms of the "aud" claim.
// An empty string unmarshals to an empty (non-nil) slice, round-tripping with
// [Listish.MarshalJSON].
func (l *Listish) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
if s == "" {
*l = Listish{}
return nil
}
*l = Listish{s}
return nil
}
var ss []string
if err := json.Unmarshal(data, &ss); err != nil {
return fmt.Errorf("aud must be a string or array of strings: %w: %w", ErrInvalidPayload, err)
}
*l = ss
return nil
}
// IsZero reports whether the list is empty (nil or zero-length).
// Used by encoding/json with the omitzero tag option.
func (l Listish) IsZero() bool { return len(l) == 0 }
// MarshalJSON encodes the list as a plain string when there is one
// value, or as a JSON array for multiple values. An empty or nil Listish
// marshals as JSON null.
func (l Listish) MarshalJSON() ([]byte, error) {
switch len(l) {
case 0:
return []byte("null"), nil
case 1:
return json.Marshal(l[0])
default:
return json.Marshal([]string(l))
}
}
// SpaceDelimited is a slice of strings that marshals as a single
// space-separated string in JSON, per RFC 6749 §3.3.
//
// It has three-state semantics:
// - nil: absent - the field is not present (omitted via omitzero)
// - non-nil empty (SpaceDelimited{}): present but empty - marshals as ""
// - populated (SpaceDelimited{"openid", "profile"}): marshals as "openid profile"
//
// UnmarshalJSON decodes a space-separated string back into a slice,
// preserving the distinction between nil (absent) and empty non-nil (present as "").
//
// https://www.rfc-editor.org/rfc/rfc6749.html#section-3.3
type SpaceDelimited []string
// UnmarshalJSON decodes a space-separated string into a slice.
// An empty string "" unmarshals to a non-nil empty SpaceDelimited{},
// preserving the distinction from a nil (absent) SpaceDelimited.
func (s *SpaceDelimited) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return fmt.Errorf("space-delimited must be a string: %w: %w", ErrInvalidPayload, err)
}
if str == "" {
*s = SpaceDelimited{}
return nil
}
*s = strings.Fields(str)
return nil
}
// IsZero reports whether the slice is absent (nil).
// Used by encoding/json with the omitzero tag option to omit the field
// when it is nil, while still marshaling a non-nil empty SpaceDelimited as "".
func (s SpaceDelimited) IsZero() bool { return s == nil }
// MarshalJSON encodes the slice as a single space-separated string.
// A nil SpaceDelimited marshals as JSON null (but is typically omitted via omitzero).
// A non-nil empty SpaceDelimited marshals as the empty string "".
func (s SpaceDelimited) MarshalJSON() ([]byte, error) {
if s == nil {
return []byte("null"), nil
}
return json.Marshal(strings.Join(s, " "))
}
// NullBool represents a boolean that can be null, true, or false.
// Used for OIDC *_verified fields where null means "not applicable"
// (the corresponding value is absent), false means "present but not
// verified", and true means "verified".
type NullBool struct {
Bool bool
Valid bool // Valid is true if Bool is not NULL
}
// IsZero reports whether nb is the zero value (not valid).
// Used by encoding/json with the omitzero tag option.
func (nb NullBool) IsZero() bool { return !nb.Valid }
// MarshalJSON encodes the NullBool as JSON. If !Valid, it outputs null;
// otherwise it outputs true or false.
func (nb NullBool) MarshalJSON() ([]byte, error) {
if !nb.Valid {
return []byte("null"), nil
}
if nb.Bool {
return []byte("true"), nil
}
return []byte("false"), nil
}
// UnmarshalJSON decodes a JSON value into a NullBool.
// null -> {false, false}; true -> {true, true}; false -> {false, true}.
func (nb *NullBool) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
nb.Bool = false
nb.Valid = false
return nil
}
var b bool
if err := json.Unmarshal(data, &b); err != nil {
return err
}
nb.Bool = b
nb.Valid = true
return nil
}