在之前的探索中,我们已经实现了通过 gRPC 自主注册懒猫 SSO 应用,并成功集成了符合 OpenID Connect (OIDC) 协议的应用。今天我突发奇想:既然大家都是标准的 OIDC 协议,我能不能把“懒猫SSO”外挂到其他的身份提供商(IDP)里,作为一种身份联邦(Identity Federation)来使用?

如果这一步能走通,意味着我们可以实现用户系统的共享。说干就干,我选择了 AWS Cognito 作为认证中间层,尝试把懒猫SSO集成进去。

初探:环境配置与客户端注册

AWS Cognito 支持添加第三方 OIDC IDP。首先,我们需要在懒猫 SSO 中为 Cognito 注册一个“身份”。

老规矩还是先使用懒猫SSO的API注册应用,当然这里的配置仍然保存在内存中,重启会丢失,所以就算一个拓宽的使用场景。使用 grpcurl 调用 CreateClient 接口,关键点在于配置 Cognito 的回调地址:

1
2
3
4
5
6
7
8
9
10
11
./grpcurl -plaintext -d '{
"client": {
"id": "congnito",
"secret": "congnito-secret",
"name": "New Flask App",
"redirect_uris": [
"https://<your-cognito-domain>.auth.us-west-2.amazoncognito.com/oauth2/idpresponse",
"http://localhost:8080/auth/callback"
]
}
}' 172.18.0.2:5557 api.Dex/CreateClient

这样我们就配置好了Congito的回调,当然还有本地的localhost和127.0.0.1的回调。

image.png

随后,在 AWS Cognito 控制台中新建一个 OIDC 提供商,填入对应的 client_idsecret。此时,Cognito 实际上成了懒猫 SSO 的一个“客户端”。

image.png

配置的时候Cognito提示无法解析懒猫域名,所以这里把URL分开来填写:

image.png

渐入:深入联邦身份原理

然后尝试代码如下,因为Cognito做了中间层,所以这里的信息是Cognito的,然后登陆的页面有一个选项可以跳转到懒猫SSO。

image.png

理想很丰满,现实很骨感。当我尝试通过 Cognito 页面跳转懒猫 SSO 登录时,程序报错了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
from flask import Flask, redirect, url_for, session, jsonify
from authlib.integrations.flask_client import OAuth
from functools import wraps
import os

app = Flask(__name__)
app.secret_key = os.urandom(24)
oauth = OAuth(app)

oauth.register(
name='sso',
client_id='',
client_secret='',
server_metadata_url='https://cognito-idp.us-west-2.amazonaws.com/us-west-xxx/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email'},
)

def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'user' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated

@app.route('/')
def index():
user = session.get('user')
if user:
return f'Hello, {user.get("email", user.get("name", "unknown"))}. <a href="/profile">Profile</a> | <a href="/logout">Logout</a>'
return 'Welcome! Please <a href="/login">Login</a>.'

@app.route('/login')
def login():
return oauth.dex.authorize_redirect(
url_for('authorize', _external=True),
identity_provider='COGNITO'
)

@app.route('/auth/callback')
def authorize():
token = oauth.dex.authorize_access_token()
session['user'] = token.get('userinfo')
session['token_info'] = {
'access_token': token.get('access_token'),
'id_token': token.get('id_token'),
'token_type': token.get('token_type'),
'expires_at': token.get('expires_at'),
}
return redirect(url_for('index'))

@app.route('/profile')
@login_required
def profile():
return jsonify(userinfo=session['user'], token=session.get('token_info'))

@app.route('/logout')
def logout():
session.pop('user', None)
return redirect(url_for('index'))

if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, debug=True)

经过数天的排查,我定位到了问题的核心:网络隔离与双向通信。
Cognito 作为一个公有云服务,在执行 OIDC 协商的时需要访问懒猫 SSO 的接口才能够正常工作。

image.png

但由于懒猫 SSO 部署在私有微服环境下,虽然我的懒猫微服能够访问互联网,但是Cognito 的服务器缺无法解析我的私有域名,更无法穿透内网进行通信,导致请求超时,最终我在日志中翻到了HTTP 400。

曲终:另辟蹊径的“重定向”方案

于是不甘心,想了一个折中的办法,是不是可以把这个跳转逻辑放到浏览器里来做呢?
想了两个办法:

  1. 直接让浏览器代替Cognito的跳转,多次尝试无果
  2. 在Cognito返回失败的时候捕获error,然后在本地302跳转到懒猫SSO

幸运的是方案2是工作的,虽然有种欺骗的味道,但是似乎是达成了公有的IDP外挂懒猫IDP的假象。首先把Cognito的注册都关掉,但是保留登陆功能,这样就没有人可以通过Cognito进行登陆,然后就只能点击懒猫SSO登陆,这个时候就会重新协商OpenID Connect协议,当我开启懒猫微服客户端的时候,我可以解析域名,别人哪怕能够跳转也无法做域名解析,所以很安全,其他人无法注册和登录Cognito,也无法解析懒猫微服域名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
from flask import Flask, redirect, url_for, session, request
from authlib.integrations.flask_client import OAuth
from authlib.integrations.base_client.errors import OAuthError
import os
import logging

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s')
log = logging.getLogger(__name__)

app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-prod')
oauth = OAuth(app)

oauth.register(
name='cognito',
client_id='',
client_secret='',
server_metadata_url='https://cognito-idp.us-west-2.amazonaws.com/us-west-xxx/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email'}
)

oauth.register(
name='dex',
client_id='Congnito',
client_secret='Congnito-secret',
server_metadata_url='https://x.heiyu.space/sys/oauth/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email'}
)


@app.route('/')
def index():
user = session.get('user')
if user:
return f'Hello, {user.get("email", user.get("sub"))}. <a href="/logout">Logout</a>'
return 'Welcome! <a href="/login">Login</a>'


@app.route('/login')
def login():
session['provider'] = 'cognito'
redirect_uri = url_for('callback', _external=True)
return oauth.cognito.authorize_redirect(redirect_uri)


@app.route('/auth/callback')
def callback():
provider = session.get('provider', 'cognito')
log.info(f'Callback - provider: {provider}, args: {dict(request.args)}')

# Cognito 失败,fallback 到 懒猫SSO 直连
if provider == 'cognito' and request.args.get('error'):
log.warning(f'Cognito failed: {request.args.get("error")}, falling back to Dex')
session['provider'] = 'dex'
redirect_uri = url_for('callback', _external=True)
return oauth.dex.authorize_redirect(redirect_uri)

try:
client = oauth.cognito if provider == 'cognito' else oauth.dex
token = client.authorize_access_token()
session['user'] = token.get('userinfo')
session.pop('provider', None)
return redirect(url_for('index'))
except OAuthError:
if provider == 'cognito':
log.warning('Cognito token exchange failed, falling back to Dex')
session['provider'] = 'dex'
redirect_uri = url_for('callback', _external=True)
return oauth.dex.authorize_redirect(redirect_uri)
raise


@app.route('/logout')
def logout():
session.pop('user', None)
return redirect(url_for('index'))


if __name__ == '__main__':
app.run(debug=True, port=8080)

别急,如果你看到了这个页面只能说是域名跳转成功,并不是OIDC的凭证交换。

image.png

输入用户名和密码之后出现这个页面就对了:
image.png

登陆之后就换到claim信息了,可以看到打印出来了邮箱。

image.png

总结

没白折腾,确实还挺抽象的,抽空又复习了Oauth和OpenID Connect的底层原理,通过使用懒猫SSO,我的技术栈又升级了。