### 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 Updatetags/v0.19.0
| @@ -20,7 +20,7 @@ oauth_config = { | |||
| "authorization_url": "https://provider.com/oauth/authorize", | |||
| "token_url": "https://provider.com/oauth/token", | |||
| "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 | |||
| @@ -29,7 +29,7 @@ oidc_config = { | |||
| "issuer": "https://provider.com/v1/oidc", | |||
| "client_id": "your_client_id", | |||
| "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 | |||
| @@ -102,5 +102,7 @@ class OAuthClient: | |||
| email = user_info.get("email") | |||
| username = user_info.get("username", str(email).split("@")[0]) | |||
| 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) | |||
| @@ -39,6 +39,7 @@ class OIDCClient(OAuthClient): | |||
| }) | |||
| super().__init__(config) | |||
| self.issuer = config['issuer'] | |||
| self.jwks_uri = config['jwks_uri'] | |||
| @@ -60,11 +61,8 @@ class OIDCClient(OAuthClient): | |||
| Parse and validate OIDC ID Token (JWT format) with signature verification. | |||
| """ | |||
| 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) | |||
| kid = headers.get("kid") | |||
| if not kid: | |||
| raise ValueError("ID Token missing 'kid' in header") | |||
| # OIDC usually uses `RS256` for signing | |||
| alg = headers.get("alg", "RS256") | |||
| @@ -79,7 +77,7 @@ class OIDCClient(OAuthClient): | |||
| id_token, | |||
| key=signing_key, | |||
| algorithms=[alg], | |||
| audience=self.client_id, | |||
| audience=str(self.client_id), | |||
| issuer=self.issuer, | |||
| ) | |||
| return decoded_token | |||
| @@ -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): | |||
| channel_config = settings.OAUTH_CONFIG.get(channel) | |||
| if not channel_config: | |||
| @@ -171,7 +194,7 @@ def oauth_callback(channel): | |||
| users = user_register( | |||
| user_id, | |||
| { | |||
| "access_token": access_token, | |||
| "access_token": get_uuid(), | |||
| "email": user_info.email, | |||
| "avatar": avatar, | |||
| "nickname": user_info.nickname, | |||
| @@ -189,7 +212,7 @@ def oauth_callback(channel): | |||
| # Try to log in | |||
| user = users[0] | |||
| 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: | |||
| rollback_user_registration(user_id) | |||
| @@ -201,8 +224,9 @@ def oauth_callback(channel): | |||
| user.access_token = get_uuid() | |||
| login_user(user) | |||
| user.save() | |||
| return redirect(f"/?auth_success=true&user_id={user.get_id()}") | |||
| return redirect(f"/?auth={user.get_id()}") | |||
| except Exception as e: | |||
| logging.exception(e) | |||
| return redirect(f"/?error={str(e)}") | |||
| @@ -75,11 +75,13 @@ redis: | |||
| # grant_type: 'authorization_code' | |||
| # custom_channel: | |||
| # type: oidc | |||
| # icon: sso | |||
| # display_name: "Custom Channel" | |||
| # issuer: https://provider.com/v1/oidc | |||
| # client_id: xxxxxxxxxxxxxxxxxxxxxxxxx | |||
| # client_secret: xxxxxxxxxxxxxxxxxxxxxxxx | |||
| # 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: | |||
| # client: | |||
| # switch: false | |||
| @@ -87,11 +87,13 @@ redis: | |||
| # grant_type: 'authorization_code' | |||
| # custom_channel: | |||
| # type: oidc | |||
| # icon: sso | |||
| # display_name: "Custom Channel" | |||
| # issuer: https://provider.com/v1/oidc | |||
| # client_id: xxxxxxxxxxxxxxxxxxxxxxxxx | |||
| # client_secret: xxxxxxxxxxxxxxxxxxxxxxxx | |||
| # 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: | |||
| # client: | |||
| # switch: false | |||