mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
Adds new Authorized Integration claim comparison rules for "in a list" and "in a list of globs", which would be required to permit multiple Forgejo Action events to match a JWT (per [design work](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-14510514), [comment](https://codeberg.org/forgejo/forgejo/issues/3571#issuecomment-14512185)). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12482 Reviewed-by: Gusted <gusted@noreply.codeberg.org>
512 lines
20 KiB
Go
512 lines
20 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package method
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
auth_model "forgejo.org/models/auth"
|
|
user_model "forgejo.org/models/user"
|
|
"forgejo.org/modules/cache"
|
|
"forgejo.org/modules/hostmatcher"
|
|
"forgejo.org/modules/json"
|
|
"forgejo.org/modules/jwtx"
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/optional"
|
|
"forgejo.org/modules/proxy"
|
|
"forgejo.org/modules/setting"
|
|
"forgejo.org/modules/timeutil"
|
|
"forgejo.org/modules/util"
|
|
"forgejo.org/services/auth"
|
|
"forgejo.org/services/authz"
|
|
|
|
"github.com/gobwas/glob"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
var (
|
|
_ auth.Method = &AuthorizedIntegration{}
|
|
|
|
aiHTTPClient *http.Client
|
|
initHTTPClient sync.Once
|
|
|
|
errParseInternalServer = errors.New("internal server error")
|
|
|
|
// Allow mocking / overridding during tests:
|
|
GetAuthorizedIntegrationHTTPClient = func() *http.Client {
|
|
initHTTPClient.Do(initAuthorizedIntegrationHTTPClient)
|
|
return aiHTTPClient
|
|
}
|
|
getCache = cache.GetCache
|
|
|
|
internalIssuers = make(map[string]InternalIssuer)
|
|
)
|
|
|
|
// Authorized Integrations can verify the signature of JWTs that the application itself generated without requiring
|
|
// remote access, and in a manner that is flexible to changes in [setting.AppURL].
|
|
//
|
|
// For example, Forgejo Actions is often used to access Forgejo with a JWT, by setting `enable-openid-connect: true` in
|
|
// a workflow. Without any special support for this internal access situation, problems would occur:
|
|
//
|
|
// 1. Forgejo would need to make an HTTP request to itself to get the valid public key for the JWT, in order to validate
|
|
// its signature. This is a waste of resources, and introduces a self-DoS risk.
|
|
//
|
|
// 2. Forgejo would need to be available via TLS in order for Actions to make service calls to Forgejo with that JWT
|
|
// (due to the TLS requirement for public key fetching).
|
|
//
|
|
// 3. Authorized Integrations would need to be saved with the `issuer` URL of Forgejo. If Forgejo's own
|
|
// [setting.AppURL] changed, all the persisted records in the database would become incorrect.
|
|
//
|
|
// Internal Issuers work by registering a URL suffix like "/api/actions". When a JWT is received with an issuer
|
|
// matching [setting.AppURL] and the registered URL suffix, then the [InternalIssuer] interface is used to access the
|
|
// JWT public key, and the value to be saved in the Authorized Integrations table as the issuer.
|
|
func RegisterInternalIssuer(urlSuffix string, internalIssuer InternalIssuer) {
|
|
internalIssuers[urlSuffix] = internalIssuer
|
|
}
|
|
|
|
//mockery:generate: true
|
|
type InternalIssuer interface {
|
|
// Signing key used to validate a JWT from this internal issuer.
|
|
SigningKey() jwtx.SigningKey
|
|
// Value to store in [auth_model.AuthorizedIntegration]'s Issuer field to reflect the use of this internal issuer.
|
|
IssuerPlaceholder() string
|
|
}
|
|
|
|
// Restrict document size to prevent resource exhaustion attack with a malicious authorized integration; largest
|
|
// real-world openid-configuration observed is about 1kB, largest JWKS is 6kB, so for both cases 16kB should be
|
|
// sufficient. If this needs to change in the future, it could be moved to a config setting -- but until a reason comes
|
|
// up it seems reasonable to keep microscopic settings out-of-sight.
|
|
const authorizedIntegrationRequestBodyLimit = int64(16 * 1024)
|
|
|
|
// Authenticates incoming requests by JWTs that are issued by an authorized integration. Authorized integrations are
|
|
// stored in the database in the [auth_model.AuthorizedIntegration] table. Once authenticated, the request can perform
|
|
// actions as the owner of the authorized integration, with limited access defined by the scope and resources stored on
|
|
// the database record.
|
|
//
|
|
// Authorization is received from HTTP requests as a `Authorization: Bearer [...jwt...]` (or `Authorization: Token
|
|
// [...jwt...]`).
|
|
type AuthorizedIntegration struct {
|
|
// Permit the use of `Authorization: Basic ...`, in addition to the typical bearer/token authorization header. If
|
|
// true, the basic password will be interpreted as a JWT token if present and valid. The username is ignored.
|
|
PermitBasic bool
|
|
|
|
// For testing -- interpret JWTs with now as a fixed time.
|
|
fixedTime *time.Time
|
|
}
|
|
|
|
func (a *AuthorizedIntegration) Verify(req *http.Request, w http.ResponseWriter, _ auth.SessionStore) auth.MethodOutput {
|
|
hasToken, token := tokenFromAuthorizationBearer(req).Get()
|
|
if !hasToken {
|
|
if !a.PermitBasic {
|
|
return &auth.AuthenticationNotAttempted{}
|
|
}
|
|
hasBasic, basicToken := tokenFromAuthorizationBasic(req).Get()
|
|
if !hasBasic {
|
|
return &auth.AuthenticationNotAttempted{}
|
|
}
|
|
token = basicToken
|
|
}
|
|
|
|
var authorizedIntegration *auth_model.AuthorizedIntegration
|
|
|
|
parsedToken, err := jwt.ParseWithClaims(token, &flexibleClaims{},
|
|
func(t *jwt.Token) (any, error) {
|
|
keyID, ok := t.Header["kid"]
|
|
if !ok {
|
|
return nil, errors.New("failed finding key identifer (kid) in JWT headers")
|
|
}
|
|
|
|
issuer, err := t.Claims.GetIssuer()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed getting `iss` claim: %w", err)
|
|
} else if len(issuer) == 0 {
|
|
return nil, fmt.Errorf("invalid `iss` claim: %q", issuer)
|
|
}
|
|
audienceArray, err := t.Claims.GetAudience()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed getting `aud` claim: %w", err)
|
|
} else if len(audienceArray) != 1 {
|
|
return nil, fmt.Errorf("required one and only one `aud` claim, but received %d", len(audienceArray))
|
|
}
|
|
audience := audienceArray[0]
|
|
if len(audience) == 0 {
|
|
return nil, fmt.Errorf("invalid `aud` claim: %q", audience)
|
|
}
|
|
|
|
// Check if there's an internal issuer that matches the JWT's issuer, and if so, change `queryIssuer` to the
|
|
// internal issuer's placeholder, and store `internalIssuer` for later:
|
|
queryIssuer := issuer
|
|
var internalIssuer InternalIssuer
|
|
issuerSuffix := strings.TrimPrefix(issuer, setting.AppURL)
|
|
if issuer != issuerSuffix { // TrimPrefix will return a different string when the prefix was present
|
|
if ii, ok := internalIssuers[issuerSuffix]; ok {
|
|
internalIssuer = ii
|
|
queryIssuer = internalIssuer.IssuerPlaceholder()
|
|
}
|
|
}
|
|
|
|
authorizedIntegration, err = auth_model.GetAuthorizedIntegration(req.Context(), queryIssuer, audience)
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
return nil, errors.New("matching authorized_integration not found")
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("failure reading authorized_integration: %w (%w)", err, errParseInternalServer)
|
|
}
|
|
|
|
// Do the claim check before accessing the issuer's OIDC metadata and JWKS, to reduce risk of resource
|
|
// utilization attack through invalid JWTs causing remote requests.
|
|
err = a.checkClaims(t.Claims.(*flexibleClaims), authorizedIntegration.ClaimRules)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim mismatch: %w", err)
|
|
}
|
|
|
|
// If an internal issuer was found earlier, then we can skip the JWKS fetch and just use its in-memory
|
|
// signing key to validate the JWT. It is critical we do this after the `checkClaims` above so that we
|
|
// don't miss important validation of the JWT.
|
|
if internalIssuer != nil {
|
|
key := internalIssuer.SigningKey().VerifyKey()
|
|
return key, nil
|
|
}
|
|
|
|
issuerURL, err := url.Parse(issuer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed parsing issuer: %w", err)
|
|
}
|
|
|
|
issuerOIDCURL := issuerURL.JoinPath(".well-known/openid-configuration")
|
|
var oidcConfig openIDConfiguration
|
|
if err := authorizedIntegrationFetchJSON(issuerOIDCURL.String(), &oidcConfig); err != nil {
|
|
return nil, fmt.Errorf("error when fetching .well-known/openid-configuration from %s: %w", issuerOIDCURL, err)
|
|
}
|
|
|
|
if oidcConfig.Issuer != issuer {
|
|
return nil, fmt.Errorf("issuer mismatch; expected %q, received %q from %s", issuer, oidcConfig.Issuer, issuerOIDCURL)
|
|
}
|
|
if !slices.Contains(oidcConfig.IDTokenSigningAlgValuesSupported, t.Method.Alg()) {
|
|
return nil, fmt.Errorf("issuer supports signature algorithms %#v, but received token with algorithm %s", oidcConfig.IDTokenSigningAlgValuesSupported, t.Method.Alg())
|
|
}
|
|
|
|
jwksURI, err := url.Parse(oidcConfig.JwksURI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed parsing jwks_uri: %w", err)
|
|
} else if jwksURI.Host != issuerURL.Host {
|
|
// Prevent SSRF which could occur if a malicious openid-connection response returned a jwks_uri field
|
|
// that causes Forgejo to access other hostnames. This could be considered a valid case as well and we
|
|
// can rely on the config-based allowed and blocked domains for the [authorized_integration] section,
|
|
// but until a real-world case comes up where that is needed, this is a safety-first restriction.
|
|
return nil, fmt.Errorf("jwks_uri host mismatch: must be the same as issuer host %q, but was %q", issuerURL.Host, jwksURI.Host)
|
|
}
|
|
var keys openIDKeys
|
|
if err := authorizedIntegrationFetchJSON(oidcConfig.JwksURI, &keys); err != nil {
|
|
return nil, fmt.Errorf("error when fetching JWKS from %s: %w", oidcConfig.JwksURI, err)
|
|
}
|
|
|
|
for _, key := range keys.Keys {
|
|
if key["kid"] == keyID {
|
|
alg, algPresent := key["alg"] // "alg" is an optional field
|
|
if algPresent && alg != t.Method.Alg() {
|
|
return nil, fmt.Errorf("kid %q doesn't match expected algorithm %s, was %v", keyID, t.Method.Alg(), key["alg"])
|
|
}
|
|
|
|
use, usePresent := key["use"] // "use" is also an optional field
|
|
if usePresent && use != "sig" {
|
|
return nil, fmt.Errorf("kid %q isn't designated for signing usage, was %s", keyID, key["use"])
|
|
}
|
|
|
|
pub, err := jwtx.ParseJWKToPublicKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse JWKS: %w", err)
|
|
}
|
|
return pub, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("no key identified")
|
|
},
|
|
jwt.WithValidMethods(jwtx.ValidAsymmetricAlgorithms), // only asymetric algorithms, as JWKS must have a public key only
|
|
jwt.WithIssuedAt(),
|
|
jwt.WithTimeFunc(func() time.Time {
|
|
if a.fixedTime != nil {
|
|
return *a.fixedTime
|
|
}
|
|
return time.Now()
|
|
}),
|
|
)
|
|
if err != nil && errors.Is(err, errParseInternalServer) {
|
|
// Errors from parsing marked errParseInternalServer are AuthenticationError, not incorrect creds:
|
|
return &auth.AuthenticationError{Error: err}
|
|
} else if err != nil {
|
|
return &auth.AuthenticationAttemptedIncorrectCredential{Error: fmt.Errorf("authorized integration: parse JWT error: %w", err)}
|
|
} else if !parsedToken.Valid {
|
|
return &auth.AuthenticationAttemptedIncorrectCredential{Error: errors.New("authorized integration: JWT not valid")}
|
|
} else if authorizedIntegration == nil { // shouldn't be possible, but overly safe
|
|
return &auth.AuthenticationError{Error: errors.New("authorized integration: nil authorized integration")}
|
|
}
|
|
|
|
u, err := user_model.GetUserByID(req.Context(), authorizedIntegration.UserID)
|
|
if err != nil {
|
|
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration: GetUserByID: %w", err)}
|
|
}
|
|
|
|
if err = authorizedIntegration.UpdateLastUsed(req.Context()); err != nil {
|
|
log.Error("UpdateLastUsed: %v", err)
|
|
}
|
|
|
|
reducer, err := authz.GetAuthorizationReducerForAuthorizedIntegration(req.Context(), authorizedIntegration)
|
|
if err != nil {
|
|
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetAuthorizationReducerForAuthorizedIntegration: %w", err)}
|
|
}
|
|
|
|
var optionalExp optional.Option[timeutil.TimeStamp]
|
|
if exp, err := parsedToken.Claims.GetExpirationTime(); err != nil {
|
|
return &auth.AuthenticationError{Error: fmt.Errorf("authorized integration GetExpirationTime: %w", err)}
|
|
} else if exp != nil {
|
|
optionalExp = optional.Some(timeutil.TimeStamp(exp.Unix()))
|
|
}
|
|
|
|
return &auth.AuthenticationSuccess{
|
|
Result: &authorizedIntegrationAuthenticationResult{
|
|
user: u,
|
|
scope: authorizedIntegration.Scope,
|
|
reducer: reducer,
|
|
expiresAt: optionalExp,
|
|
},
|
|
}
|
|
}
|
|
|
|
func initAuthorizedIntegrationHTTPClient() {
|
|
blockList := hostmatcher.ParseSimpleMatchList("authorized_integration.BLOCKED_DOMAINS", setting.AuthorizedIntegration.BlockedDomains)
|
|
|
|
allowList := hostmatcher.ParseSimpleMatchList("authorized_integration.ALLOWED_DOMAINS", setting.AuthorizedIntegration.AllowedDomains)
|
|
if allowList.IsEmpty() {
|
|
// the default policy is that authorized integrations can access external hosts
|
|
allowList.AppendBuiltin(hostmatcher.MatchBuiltinExternal)
|
|
}
|
|
if setting.AuthorizedIntegration.AllowLocalNetworks {
|
|
allowList.AppendBuiltin(hostmatcher.MatchBuiltinPrivate)
|
|
allowList.AppendBuiltin(hostmatcher.MatchBuiltinLoopback)
|
|
}
|
|
|
|
aiHTTPClient = &http.Client{
|
|
Timeout: setting.AuthorizedIntegration.RequestTimeout,
|
|
Transport: &http.Transport{
|
|
Proxy: proxy.Proxy(),
|
|
DialContext: hostmatcher.NewDialContext("authorized_integration", allowList, blockList, setting.Proxy.ProxyURLFixed),
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// It might be possible to come up with some reasonable capability to support redirects -- such as
|
|
// keeping them within the same issuer host? -- but there are risks that this can be used for SSRF
|
|
// attacks. In the face of those risks, and with a lack of real-world use-cases, disable redirects.
|
|
return errors.New("authorized integration: HTTP redirects are disabled")
|
|
},
|
|
}
|
|
}
|
|
|
|
func authorizedIntegrationCacheKey(urlString string) string {
|
|
return fmt.Sprintf("auth-int-remote:%s", urlString)
|
|
}
|
|
|
|
func authorizedIntegrationCacheGetJSON[K any](urlString string, v *K) bool {
|
|
conn := getCache()
|
|
if conn == nil {
|
|
return false
|
|
}
|
|
|
|
cachedAny := conn.Get(authorizedIntegrationCacheKey(urlString))
|
|
if cachedAny == nil {
|
|
return false
|
|
}
|
|
cachedBytes, ok := cachedAny.([]byte)
|
|
if !ok {
|
|
cachedString, ok := cachedAny.(string)
|
|
if !ok {
|
|
log.Error("cached content was not []byte or string, but was %T", cachedAny)
|
|
return false
|
|
}
|
|
cachedBytes = []byte(cachedString)
|
|
}
|
|
|
|
err := json.Unmarshal(cachedBytes, &v)
|
|
if err != nil {
|
|
// This error case shouldn't occur, as we only store data in the cache once we're sure we could unmarshal it.
|
|
// If it does occur, log and fallback to treating as uncached.
|
|
log.Error("failed to Unmarshal cached content: %s", err)
|
|
// Caller may reuse `v` in a future unmarshal/decode call, and failure here may have polluted it.
|
|
var zeroValue K
|
|
*v = zeroValue
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func authorizedIntegrationCacheSetJSON(urlString string, buf []byte) {
|
|
conn := getCache()
|
|
if conn == nil {
|
|
return
|
|
}
|
|
err := conn.Put(authorizedIntegrationCacheKey(urlString), buf, int64(setting.AuthorizedIntegration.CacheTTL.Seconds()))
|
|
if err != nil {
|
|
log.Error("failed to put cache: %s", err)
|
|
}
|
|
}
|
|
|
|
func authorizedIntegrationFetchJSON[K any](urlString string, v *K) error {
|
|
parsedURL, err := url.Parse(urlString)
|
|
if err != nil {
|
|
return fmt.Errorf("failed parsing URL %q: %w", urlString, err)
|
|
}
|
|
// Fetching openid-connect or JWKS needs to come from a source that is authentic, and therefore only `https` is
|
|
// supported. This also protects against a trusted issuer being configured maliciously as `file://` or a JKWS URI
|
|
// being `file://` -- the HTTP client won't permit that, but, extra safety doesn't hurt.
|
|
if parsedURL.Scheme != "https" {
|
|
return fmt.Errorf("unsupported URL scheme: %q", parsedURL.String())
|
|
}
|
|
|
|
// Check our cache, save a remote HTTP interaction.
|
|
if authorizedIntegrationCacheGetJSON(urlString, v) {
|
|
return nil
|
|
}
|
|
|
|
resp, err := GetAuthorizedIntegrationHTTPClient().Get(parsedURL.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("non-OK response code: %s", resp.Status)
|
|
}
|
|
|
|
bodyReader := io.LimitReader(resp.Body, authorizedIntegrationRequestBodyLimit)
|
|
var buf bytes.Buffer
|
|
_, err = io.Copy(bufio.NewWriter(&buf), bodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("read from remote error: %w", err)
|
|
}
|
|
|
|
err = json.Unmarshal(buf.Bytes(), &v)
|
|
if err != nil {
|
|
// If a decoding error is hit, decorate with information about the limited body size so that it doesn't look
|
|
// like the remote server provided an incomplete response. err should be something like `io.UnexpectedEOF` in
|
|
// this case, but it actually isn't, so don't bother trying to detect precisely.
|
|
return fmt.Errorf("failed to decode (response body restricted to %d bytes): %w", authorizedIntegrationRequestBodyLimit, err)
|
|
}
|
|
|
|
// Successfully decoded the response -- cache the raw bytes for later access.
|
|
authorizedIntegrationCacheSetJSON(urlString, buf.Bytes())
|
|
|
|
return nil
|
|
}
|
|
|
|
// Compare a map[string]any of incoming claims against an array of claim rules. All rules must match successfully or
|
|
// else an error with the mismatch detail is returned.
|
|
func (a *AuthorizedIntegration) checkClaims(incomingClaims any, stored *auth_model.ClaimRules) error {
|
|
if stored == nil {
|
|
return nil
|
|
}
|
|
|
|
for _, rule := range stored.Rules {
|
|
var lhs any
|
|
|
|
if lhsClaim, isFlex := incomingClaims.(*flexibleClaims); isFlex {
|
|
switch rule.Claim {
|
|
case "iss":
|
|
lhs = lhsClaim.Issuer
|
|
case "sub":
|
|
lhs = lhsClaim.Subject
|
|
case "jti":
|
|
lhs = lhsClaim.ID
|
|
case "aud":
|
|
audienceArray, err := lhsClaim.GetAudience()
|
|
if err != nil {
|
|
return fmt.Errorf("failed getting `aud` claim: %w", err)
|
|
} else if len(audienceArray) != 1 {
|
|
return fmt.Errorf("required one and only one `aud` claim, but received %d", len(audienceArray))
|
|
}
|
|
lhs = audienceArray[0]
|
|
default:
|
|
v, present := lhsClaim.other[rule.Claim]
|
|
if !present {
|
|
return fmt.Errorf("claim rule on %q couldn't be satisfied: claim not found", rule.Claim)
|
|
}
|
|
lhs = v
|
|
}
|
|
} else if lhsMap, isMap := incomingClaims.(map[string]any); isMap {
|
|
v, present := lhsMap[rule.Claim]
|
|
if !present {
|
|
return fmt.Errorf("claim rule on %q couldn't be satisfied: claim not found", rule.Claim)
|
|
}
|
|
lhs = v
|
|
} else {
|
|
return fmt.Errorf("unexpected incoming claims type: %T", incomingClaims)
|
|
}
|
|
|
|
switch rule.Comparison {
|
|
case auth_model.ClaimEqual:
|
|
lhsStr, ok := lhs.(string)
|
|
if !ok {
|
|
return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs)
|
|
} else if lhsStr != rule.Value {
|
|
return fmt.Errorf("claim %q must be %q, but was %q", rule.Claim, rule.Value, lhsStr)
|
|
}
|
|
case auth_model.ClaimIn:
|
|
lhsStr, ok := lhs.(string)
|
|
if !ok {
|
|
return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs)
|
|
} else if !slices.Contains(rule.Values, lhsStr) {
|
|
return fmt.Errorf("claim %q must be one of %q, but was %q", rule.Claim, rule.Values, lhsStr)
|
|
}
|
|
case auth_model.ClaimGlob:
|
|
lhsStr, ok := lhs.(string)
|
|
if !ok {
|
|
return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs)
|
|
}
|
|
r, err := glob.Compile(rule.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to parse glob for claim rule on %q; glob = %q, err = %w", rule.Claim, rule.Value, err)
|
|
}
|
|
if !r.Match(lhsStr) {
|
|
return fmt.Errorf("claim %q must match glob %q, but value %q did not match", rule.Claim, rule.Value, lhsStr)
|
|
}
|
|
case auth_model.ClaimGlobIn:
|
|
lhsStr, ok := lhs.(string)
|
|
if !ok {
|
|
return fmt.Errorf("claim %q must be a string, but was %T", rule.Claim, lhs)
|
|
}
|
|
matched := false
|
|
for _, g := range rule.Values {
|
|
r, err := glob.Compile(g)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to parse glob for claim rule on %q; glob = %q, err = %w", rule.Claim, g, err)
|
|
}
|
|
if r.Match(lhsStr) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
return fmt.Errorf("claim %q must glob match one of %q, but value %q did not match", rule.Claim, rule.Values, lhsStr)
|
|
}
|
|
case auth_model.ClaimNested:
|
|
lhsMap, ok := lhs.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("claim %q must be a map, but was %T", rule.Claim, lhs)
|
|
} else if err := a.checkClaims(lhsMap, rule.Nested); err != nil {
|
|
return fmt.Errorf("in nested claim %q: %w", rule.Claim, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|