From 5a13b1037cf3413c72e2267cc5ee0b3d1eb779f6 Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 15 Jun 2026 15:06:29 +0800 Subject: [PATCH] feat(oidc): add OIDC provider support --- inventory/setting.go | 6 +- pkg/auth/oidc.go | 123 +++++++++++++++++++++++++++++++++++ routers/controllers/oauth.go | 17 +++++ routers/router.go | 2 + service/oauth/oauth.go | 47 ++++++++++++- service/oauth/oidc.go | 77 ++++++++++++++++++++++ service/oauth/response.go | 18 +++++ 7 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 pkg/auth/oidc.go create mode 100644 service/oauth/oidc.go diff --git a/inventory/setting.go b/inventory/setting.go index 3e2aef1c..34ebe6f2 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -524,6 +524,7 @@ var DefaultSettings = map[string]string{ "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "max_parallel_transfer": `4`, "secret_key": util.RandStringRunesCrypto(256), + "oidc_signing_private_key": "", "temp_path": "temp", "avatar_path": "avatar", "avatar_size": "4194304", @@ -688,8 +689,9 @@ var DefaultSettings = map[string]string{ } var RedactedSettings = map[string]struct{}{ - "encrypt_master_key": {}, - "secret_key": {}, + "encrypt_master_key": {}, + "secret_key": {}, + "oidc_signing_private_key": {}, } func init() { diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 00000000..987ac250 --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/golang-jwt/jwt/v5" +) + +const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" + +type OIDCIDTokenClaims struct { + jwt.RegisteredClaims + Nonce string `json:"nonce,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Picture string `json:"picture,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` +} + +type JWKSet struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +func SignOIDCIDToken(ctx context.Context, settingClient inventory.SettingClient, claims *OIDCIDTokenClaims) (string, error) { + key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + return token.SignedString(key) +} + +func OIDCJWKSet(ctx context.Context, settingClient inventory.SettingClient) (*JWKSet, error) { + key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) + if err != nil { + return nil, err + } + + return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil +} + +func loadOrCreateOIDCSigningKey(ctx context.Context, settingClient inventory.SettingClient) (*rsa.PrivateKey, string, error) { + privateKeyRaw, err := settingClient.Get(ctx, OIDCSigningPrivateKeySetting) + if err != nil && !ent.IsNotFound(err) { + return nil, "", fmt.Errorf("failed to load OIDC signing key: %w", err) + } + if privateKeyRaw != "" { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return nil, "", err + } + return key, oidcSigningKeyID(&key.PublicKey), nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", fmt.Errorf("failed to generate OIDC signing key: %w", err) + } + + privateKeyRaw = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) + if err := settingClient.Set(ctx, map[string]string{OIDCSigningPrivateKeySetting: privateKeyRaw}); err != nil { + return nil, "", fmt.Errorf("failed to persist OIDC signing key: %w", err) + } + + return key, oidcSigningKeyID(&key.PublicKey), nil +} + +func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyRaw)) + if block == nil { + return nil, fmt.Errorf("invalid OIDC signing key PEM") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid OIDC signing key: %w", err) + } + + return key, nil +} + +func oidcSigningKeyID(key *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(key) + sum := sha256.Sum256(der) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func buildJWK(key *rsa.PublicKey, kid string) JWK { + return JWK{ + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } +} diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index 9c4472bf..bc196af2 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -6,6 +6,23 @@ import ( "github.com/gin-gonic/gin" ) +func OpenIDConfiguration(c *gin.Context) { + service := &oauth.DiscoveryService{} + c.JSON(200, service.Get(c)) +} + +func OpenIDJWKS(c *gin.Context) { + service := &oauth.JWKService{} + res, err := service.Get(c) + if err != nil { + c.JSON(500, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, res) +} + func GetAppRegistration(c *gin.Context) { service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) app, err := service.Get(c) diff --git a/routers/router.go b/routers/router.go index 7c5a84cd..8ed0bbfb 100644 --- a/routers/router.go +++ b/routers/router.go @@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { */ r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) r.Use(middleware.SharePreview(dep)) + r.GET(".well-known/openid-configuration", controllers.OpenIDConfiguration) r.Use(middleware.FrontendFileHandler(dep)) r.GET("manifest.json", controllers.Manifest) @@ -333,6 +334,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}), controllers.ExchangeToken, ) + oauthRouter.GET("jwks", controllers.OpenIDJWKS) oauthRouter.GET("userinfo", middleware.LoginRequired(), controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index a15940e1..bdf71f9a 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -16,6 +16,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/samber/lo" ) @@ -50,6 +51,7 @@ type ( ResponseType string `json:"response_type" binding:"required,eq=code"` RedirectURI string `json:"redirect_uri" binding:"required"` State string `json:"state" binding:"max=4096"` + Nonce string `json:"nonce" binding:"max=4096"` Scope string `json:"scope" binding:"required"` CodeChallenge string `json:"code_challenge" binding:"max=255"` CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"` @@ -84,7 +86,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { } // Parse requested scopes (space-separated per OAuth 2.0 spec) - requestedScopes := strings.Split(s.Scope, " ") + requestedScopes := strings.Fields(s.Scope) // Validate requested scopes: must be a subset of registered app scopes if !auth.ValidateScopes(requestedScopes, app.Scopes) { @@ -108,6 +110,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { UserID: user.ID, Scopes: requestedScopes, RedirectURI: s.RedirectURI, + Nonce: s.Nonce, CodeChallenge: s.CodeChallenge, } @@ -129,6 +132,7 @@ type ( ClientSecret string `form:"client_secret" binding:"required"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` + RedirectURI string `form:"redirect_uri" binding:"required"` CodeVerifier string `form:"code_verifier"` } ) @@ -159,6 +163,9 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) if authCode.ClientID != s.ClientID { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) } + if authCode.RedirectURI != s.RedirectURI { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil) + } // 3. Verify PKCE: SHA256(code_verifier) should match code_challenge if authCode.CodeChallenge != "" { @@ -230,9 +237,47 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) } } + if lo.Contains(authCode.Scopes, types.ScopeOpenID) { + idToken, err := buildIDToken(c, dep, user, s.ClientID, authCode.Scopes, token.AccessExpires, authCode.Nonce) + if err != nil { + return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue ID token", err) + } + resp.IDToken = idToken + } + return resp, nil } +func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) { + sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID) + claims := &auth.OIDCIDTokenClaims{ + Nonce: nonce, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: oidcIssuer(c).String(), + Subject: sub, + Audience: jwt.ClaimStrings{clientID}, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expires), + }, + } + + for _, scope := range scopes { + switch scope { + case types.ScopeProfile: + siteUrl := dep.SettingProvider().SiteURL(c) + claims.Name = user.Nick + claims.PreferredUsername = user.Nick + claims.Picture = routes.MasterUserAvatarUrl(siteUrl, sub).String() + claims.UpdatedAt = user.UpdatedAt.Unix() + case types.ScopeEmail: + claims.Email = user.Email + claims.EmailVerified = true + } + } + + return auth.SignOIDCIDToken(c, dep.SettingClient(), claims) +} + type ( DeleteOAuthGrantParamCtx struct{} DeleteOAuthGrantService struct { diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go new file mode 100644 index 00000000..73e4a035 --- /dev/null +++ b/service/oauth/oidc.go @@ -0,0 +1,77 @@ +package oauth + +import ( + "net/url" + + "github.com/cloudreve/Cloudreve/v4/application/constants" + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/gin-gonic/gin" +) + +type DiscoveryService struct{} + +type JWKService struct{} + +func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { + issuer := oidcIssuer(c) + return &DiscoveryResponse{ + Issuer: issuer.String(), + AuthorizationEndpoint: oidcEndpoint(issuer, "/session/authorize"), + TokenEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/token"), + UserInfoEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/userinfo"), + JWKSURI: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/jwks"), + ResponseTypesSupported: []string{ + "code", + }, + GrantTypesSupported: []string{ + "authorization_code", + }, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "RS256", + }, + TokenEndpointAuthMethods: []string{ + "client_secret_post", + }, + CodeChallengeMethodsSupported: []string{ + "S256", + }, + ScopesSupported: []string{ + types.ScopeOpenID, + types.ScopeProfile, + types.ScopeEmail, + }, + ClaimsSupported: []string{ + "sub", + "name", + "preferred_username", + "picture", + "updated_at", + "email", + "email_verified", + }, + } +} + +func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) { + dep := dependency.FromContext(c) + return auth.OIDCJWKSet(c, dep.SettingClient()) +} + +func oidcIssuer(c *gin.Context) *url.URL { + dep := dependency.FromContext(c) + issuer := *dep.SettingProvider().SiteURL(setting.UseFirstSiteUrl(c)) + issuer.RawQuery = "" + issuer.Fragment = "" + return &issuer +} + +func oidcEndpoint(issuer *url.URL, endpoint string) string { + route, _ := url.Parse(endpoint) + return issuer.ResolveReference(route).String() +} diff --git a/service/oauth/response.go b/service/oauth/response.go index 49a99df1..88d591d1 100644 --- a/service/oauth/response.go +++ b/service/oauth/response.go @@ -19,6 +19,22 @@ type AppRegistration struct { ConstentedScopes []string `json:"consented_scopes,omitempty"` } +type DiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration { res := &AppRegistration{ ID: app.GUID, @@ -50,6 +66,7 @@ type TokenResponse struct { ExpiresIn int64 `json:"expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` Scope string `json:"scope"` } @@ -77,6 +94,7 @@ type AuthorizationCode struct { UserID int `json:"user_id"` Scopes []string `json:"scopes"` RedirectURI string `json:"redirect_uri"` + Nonce string `json:"nonce"` CodeChallenge string `json:"code_challenge"` }