Feat: Add /login/channels route and improve auth logic for frontend third-party login integration (#7521)

### What problem does this PR solve?

Add `/login/channels` route and improve auth logic to support frontend
integration with third-party login providers:

- Add `/login/channels` route to provide authentication channel list
with `display_name` and `icon`
- Optimize user info parsing logic by prioritizing `avatar_url` and
falling back to `picture`
- Simplify OIDC token validation by removing unnecessary `kid` checks
- Ensure `client_id` is safely cast to string during `audience`
validation
- Fix typo

---
- Related pull request: #7379 

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
- [x] Documentation Update
This commit is contained in:
Chaoxi Weng 2025-05-08 10:23:19 +08:00 committed by GitHub
parent 014a1535f2
commit e349635a3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 42 additions and 14 deletions

View File

@ -20,7 +20,7 @@ oauth_config = {
"authorization_url": "https://provider.com/oauth/authorize", "authorization_url": "https://provider.com/oauth/authorize",
"token_url": "https://provider.com/oauth/token", "token_url": "https://provider.com/oauth/token",
"userinfo_url": "https://provider.com/oauth/userinfo", "userinfo_url": "https://provider.com/oauth/userinfo",
"redirect_uri": "https://your-app.com/oauth/callback/<channel>" "redirect_uri": "https://your-app.com/v1/user/oauth/callback/<channel>"
} }
# OIDC configuration # OIDC configuration
@ -29,7 +29,7 @@ oidc_config = {
"issuer": "https://provider.com/v1/oidc", "issuer": "https://provider.com/v1/oidc",
"client_id": "your_client_id", "client_id": "your_client_id",
"client_secret": "your_client_secret", "client_secret": "your_client_secret",
"redirect_uri": "https://your-app.com/oauth/callback/<channel>" "redirect_uri": "https://your-app.com/v1/user/oauth/callback/<channel>"
} }
# Get client instance # Get client instance

View File

@ -102,5 +102,7 @@ class OAuthClient:
email = user_info.get("email") email = user_info.get("email")
username = user_info.get("username", str(email).split("@")[0]) username = user_info.get("username", str(email).split("@")[0])
nickname = user_info.get("nickname", username) nickname = user_info.get("nickname", username)
avatar_url = user_info.get("picture", "") avatar_url = user_info.get("avatar_url", None)
if avatar_url is None:
avatar_url = user_info.get("picture", "")
return UserInfo(email=email, username=username, nickname=nickname, avatar_url=avatar_url) return UserInfo(email=email, username=username, nickname=nickname, avatar_url=avatar_url)

View File

@ -39,6 +39,7 @@ class OIDCClient(OAuthClient):
}) })
super().__init__(config) super().__init__(config)
self.issuer = config['issuer']
self.jwks_uri = config['jwks_uri'] self.jwks_uri = config['jwks_uri']
@ -60,11 +61,8 @@ class OIDCClient(OAuthClient):
Parse and validate OIDC ID Token (JWT format) with signature verification. Parse and validate OIDC ID Token (JWT format) with signature verification.
""" """
try: try:
# Decode JWT header to extract key ID (kid) without verifying signature # Decode JWT header without verifying signature
headers = jwt.get_unverified_header(id_token) headers = jwt.get_unverified_header(id_token)
kid = headers.get("kid")
if not kid:
raise ValueError("ID Token missing 'kid' in header")
# OIDC usually uses `RS256` for signing # OIDC usually uses `RS256` for signing
alg = headers.get("alg", "RS256") alg = headers.get("alg", "RS256")
@ -79,7 +77,7 @@ class OIDCClient(OAuthClient):
id_token, id_token,
key=signing_key, key=signing_key,
algorithms=[alg], algorithms=[alg],
audience=self.client_id, audience=str(self.client_id),
issuer=self.issuer, issuer=self.issuer,
) )
return decoded_token return decoded_token

View File

@ -116,7 +116,30 @@ def login():
) )
@manager.route("/login/<channel>") # noqa: F821 @manager.route("/login/channels", methods=["GET"]) # noqa: F821
def get_login_channels():
"""
Get all supported authentication channels.
"""
try:
channels = []
for channel, config in settings.OAUTH_CONFIG.items():
channels.append({
"channel": channel,
"display_name": config.get("display_name", channel.title()),
"icon": config.get("icon", "sso"),
})
return get_json_result(data=channels)
except Exception as e:
logging.exception(e)
return get_json_result(
data=[],
message=f"Load channels failure, error: {str(e)}",
code=settings.RetCode.EXCEPTION_ERROR
)
@manager.route("/login/<channel>", methods=["GET"]) # noqa: F821
def oauth_login(channel): def oauth_login(channel):
channel_config = settings.OAUTH_CONFIG.get(channel) channel_config = settings.OAUTH_CONFIG.get(channel)
if not channel_config: if not channel_config:
@ -171,7 +194,7 @@ def oauth_callback(channel):
users = user_register( users = user_register(
user_id, user_id,
{ {
"access_token": access_token, "access_token": get_uuid(),
"email": user_info.email, "email": user_info.email,
"avatar": avatar, "avatar": avatar,
"nickname": user_info.nickname, "nickname": user_info.nickname,
@ -189,7 +212,7 @@ def oauth_callback(channel):
# Try to log in # Try to log in
user = users[0] user = users[0]
login_user(user) login_user(user)
return redirect(f"/?auth_success=true&user_id={user.get_id()}") return redirect(f"/?auth={user.get_id()}")
except Exception as e: except Exception as e:
rollback_user_registration(user_id) rollback_user_registration(user_id)
@ -201,8 +224,9 @@ def oauth_callback(channel):
user.access_token = get_uuid() user.access_token = get_uuid()
login_user(user) login_user(user)
user.save() user.save()
return redirect(f"/?auth_success=true&user_id={user.get_id()}") return redirect(f"/?auth={user.get_id()}")
except Exception as e: except Exception as e:
logging.exception(e)
return redirect(f"/?error={str(e)}") return redirect(f"/?error={str(e)}")

View File

@ -75,11 +75,13 @@ redis:
# grant_type: 'authorization_code' # grant_type: 'authorization_code'
# custom_channel: # custom_channel:
# type: oidc # type: oidc
# icon: sso
# display_name: "Custom Channel"
# issuer: https://provider.com/v1/oidc # issuer: https://provider.com/v1/oidc
# client_id: xxxxxxxxxxxxxxxxxxxxxxxxx # client_id: xxxxxxxxxxxxxxxxxxxxxxxxx
# client_secret: xxxxxxxxxxxxxxxxxxxxxxxx # client_secret: xxxxxxxxxxxxxxxxxxxxxxxx
# scope: "openid email profile" # scope: "openid email profile"
# redirect_uri: https://your-app.com/oauth/callback/custom_channel # redirect_uri: https://your-app.com/v1/user/oauth/callback/custom_channel
# authentication: # authentication:
# client: # client:
# switch: false # switch: false

View File

@ -87,11 +87,13 @@ redis:
# grant_type: 'authorization_code' # grant_type: 'authorization_code'
# custom_channel: # custom_channel:
# type: oidc # type: oidc
# icon: sso
# display_name: "Custom Channel"
# issuer: https://provider.com/v1/oidc # issuer: https://provider.com/v1/oidc
# client_id: xxxxxxxxxxxxxxxxxxxxxxxxx # client_id: xxxxxxxxxxxxxxxxxxxxxxxxx
# client_secret: xxxxxxxxxxxxxxxxxxxxxxxx # client_secret: xxxxxxxxxxxxxxxxxxxxxxxx
# scope: "openid email profile" # scope: "openid email profile"
# redirect_uri: https://your-app.com/oauth/callback/custom_channel # redirect_uri: https://your-app.com/v1/user/oauth/callback/custom_channel
# authentication: # authentication:
# client: # client:
# switch: false # switch: false