mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Auth: Support asymmetric JSON Web Tokens (JWT) and Key Sets (JWKS) #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
124
internal/api/api_auth_jwt.go
Normal file
124
internal/api/api_auth_jwt.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
|
||||
// node receives a request without an existing session. It verifies the token
|
||||
// against the node's cached JWKS, ensures the issuer/audience/scope match the
|
||||
// expected portal values, and, if valid, returns a client session mirroring the
|
||||
// JWT claims. It returns nil on any validation failure so the caller can fall
|
||||
// back to existing auth flows. Currently cluster and vision resources are
|
||||
// eligible for JWT-based authorization; vision access requires the `vision`
|
||||
// scope whereas cluster access requires the `cluster` scope.
|
||||
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
|
||||
if c == nil || authToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = perms
|
||||
|
||||
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic sanity check for JWT structure.
|
||||
if strings.Count(authToken, ".") != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
|
||||
if conf == nil || conf.IsPortal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if conf.JWKSUrl() == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
verifier := clusterjwt.NewVerifier(conf)
|
||||
requiredScopes := []string{"cluster"}
|
||||
if resource == acl.ResourceVision {
|
||||
requiredScopes = []string{"vision"}
|
||||
}
|
||||
|
||||
expected := clusterjwt.ExpectedClaims{
|
||||
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
||||
Scope: requiredScopes,
|
||||
JWKSURL: conf.JWKSUrl(),
|
||||
}
|
||||
|
||||
issuers := jwtIssuerCandidates(conf)
|
||||
|
||||
if len(issuers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
claims *clusterjwt.Claims
|
||||
err error
|
||||
)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
for _, issuer := range issuers {
|
||||
expected.Issuer = issuer
|
||||
claims, err = verifier.VerifyToken(ctx, authToken, expected)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
} else if claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sess := &entity.Session{
|
||||
Status: http.StatusOK,
|
||||
ClientUID: claims.Subject,
|
||||
AuthScope: clean.Scope(claims.Scope),
|
||||
AuthIssuer: claims.Issuer,
|
||||
AuthID: claims.ID,
|
||||
GrantType: authn.GrantJwtBearer.String(),
|
||||
AuthProvider: authn.ProviderClient.String(),
|
||||
}
|
||||
|
||||
sess.SetMethod(authn.MethodJWT)
|
||||
sess.SetClientName(claims.Subject)
|
||||
sess.SetClientIP(clientIP)
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// jwtIssuerCandidates returns the possible issuer values the node should accept
|
||||
// for Portal JWTs. It prefers the explicit portal cluster identifier and then
|
||||
// falls back to configured URLs so legacy installations migrate seamlessly.
|
||||
func jwtIssuerCandidates(conf *config.Config) []string {
|
||||
var out []string
|
||||
if uuid := conf.ClusterUUID(); uuid != "" {
|
||||
out = append(out, fmt.Sprintf("portal:%s", uuid))
|
||||
}
|
||||
if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
|
||||
out = append(out, strings.TrimRight(portal, "/"))
|
||||
}
|
||||
if site := strings.TrimSpace(conf.SiteUrl()); site != "" {
|
||||
out = append(out, strings.TrimRight(site, "/"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user