feat(oidc): add OIDC provider support

pull/3472/head
Ricky 4 weeks ago
parent 26b6b1044b
commit 5a13b1037c

@ -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() {

@ -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()),
}
}

@ -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)

@ -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{}),

@ -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 {

@ -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()
}

@ -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"`
}

Loading…
Cancel
Save