mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
Allow JWTs that are generated by Forgejo Actions to be validated within Forgejo in-memory. Without any special support for this internal access situation, these 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. This would be a blocker for writing end-to-end tests for Forgejo, but also would affect users who do not host Forgejo with TLS. 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. ## 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. - [x] 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/12364 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
486 lines
19 KiB
Go
486 lines
19 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.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.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
|
|
}
|