### 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
| "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 | ||||
| "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 |
| 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) |
| }) | }) | ||||
| super().__init__(config) | super().__init__(config) | ||||
| self.issuer = config['issuer'] | |||||
| self.jwks_uri = config['jwks_uri'] | self.jwks_uri = config['jwks_uri'] | ||||
| 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") | ||||
| 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 |
| ) | ) | ||||
| @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: | ||||
| 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, | ||||
| # 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) | ||||
| 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)}") | ||||
| # 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 |
| # 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 |