feat:add oidc wasm plugin (#568)
96
plugins/wasm-go/extensions/oidc/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 功能说明
|
||||
`oidc` 本插件实现了 OIDC 认证能力, 插件目前存在的 CSRF 攻击问题,不建议用于生产环境
|
||||
|
||||
# 配置字段
|
||||
| 字段 | 数据类型 | 填写要求 | 默认值 | 描述 |
|
||||
|-------------------|--------|------|------------|------------------------------------------------------------------|
|
||||
| issuer | string | 必填 | - | 设置认证服务的 issuer ,即签发人。 |
|
||||
| client_id | string | 必填 | - | 输入服务注册的应用 ID 。 |
|
||||
| client_secret | string | 必填 | - | 输入服务注册的应用 Secret 。 |
|
||||
| redirect_url | string | 必填 | - | 输入授权成功后的重定向地址,需要与 OIDC 中配置的重定向地址保持一致。该地址的后缀需为 (oauth2/callback)。 |
|
||||
| client_url | string | 必填 | - | 登陆成功跳转后的地址,如果未跳转成功,请检查设置的 cookiename 是否重复。 |
|
||||
| scopes | Array | 必填 | - | 输入授权作用域的数组。 |
|
||||
| skip_expiry_check | bool | 选填 | false | 控制是否检测 IDToken 的过期状态。 |
|
||||
| skip_nonce_check | bool | 选填 | true | 控制是否检测 Nonce 值。 |
|
||||
| timeout_millis | int | 选填 | 500 | 设置请求与认证服务连接的超时时长。如果频繁遇到超时错误,建议增加该时长。 |
|
||||
| cookie_name | string | 选填 | "_oidc_wasm" | 设置 cookie 的名称, 如果一个域名下多个路由设置不同的认证服务,建议设置不同名称。 |
|
||||
| cookie_domain | string | 必填 | - | 设置 cookie 的域名。 |
|
||||
| cookie_path | string | 选填 | "/" | 设置 cookie 的存储路径。 |
|
||||
| cookie_secure | bool | 选填 | false | 控制 cookie 是否只在 HTTPS 下传输。 |
|
||||
| cookie_httponly | bool | 选填 | true | 控制 cookie 是否仅限于 HTTP 传输,禁止JavaScript访问。 |
|
||||
| cookie_samesite | string | 选填 | "Lax" | 设置 cookie 的 SameSite 属性,如:"Lax", "none"。第三方跳转一般建议默认设置为Lax |
|
||||
| service_source | string | 必填 | - | 类型为固定 ip 或者 DNS ,输入认证 oidc 服务的注册来源。 |
|
||||
| service_name | string | 必填 | - | 输入认证 oidc 服务的注册名称。 |
|
||||
| service_port | int | 必填 | - | 输入认证 oidc 服务的服务端口。 |
|
||||
| service_host | string | 必填 | - | 当类型为固定ip时必须填写,输入认证 oidc 服务的主机名。 |
|
||||
| service_domain | string | 必填 | - | 当类型为DNS时必须填写,输入认证 oidc 服务的domain。 |
|
||||
|
||||
这是一个用于OIDC认证配置的表格,确保在提供所有必要的信息时遵循上述指导。
|
||||
# 配置示例
|
||||
- 固定ip
|
||||
```yaml
|
||||
issuer: "http://127.0.0.1:9090/realms/myrealm"
|
||||
redirect_url: "http://foo.bar.com/bar/oauth2/callback"
|
||||
client_url: "http://foo.bar.com/"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
cookie_name: "_oauth2_wasm_keyclocak"
|
||||
cookie_domain: "foo.bar.com"
|
||||
client_id: "xxxxxxxxxxxx"
|
||||
client_secret: "xxxxxxxxxxxxxx"
|
||||
service_host: "127.0.0.1:9090"
|
||||
service_name: "keyclocak"
|
||||
service_port: 80
|
||||
service_source: "ip"
|
||||
```
|
||||
- DNS域名
|
||||
- 在服务来源中注册好服务后,创建对应的ingress
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: example-ingress
|
||||
annotations:
|
||||
higress.io/destination: okta.dns
|
||||
higress.io/backend-protocol: "HTTPS"
|
||||
higress.io/ignore-path-case: "false"
|
||||
spec:
|
||||
ingressClassName: higress
|
||||
rules:
|
||||
- host: foo.bar.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
resource:
|
||||
apiGroup: networking.higress.io
|
||||
kind: McpBridge
|
||||
name: default
|
||||
|
||||
```
|
||||
- 创建wasm插件
|
||||
```yaml
|
||||
issuer: "https://dev-65874123.okta.com"
|
||||
redirect_url: "http://foo.bar.com/a/oauth2/callback"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
client_url: "http://foo.bar.com/a"
|
||||
cookie_domain: "foo.bar.com"
|
||||
client_id: "xxxxxxxxxxxxxxx"
|
||||
client_secret: "xxxxxxx"
|
||||
service_domain: "dev-65874123.okta.com"
|
||||
service_name: "okta"
|
||||
service_port: 443
|
||||
service_source: "dns"
|
||||
timeout_millis: 2000
|
||||
```
|
||||
|
||||
在通过插件验证后会携带 `Authorization`的标头携带令牌
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
plugins/wasm-go/extensions/oidc/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
199
plugins/wasm-go/extensions/oidc/doc/Oidc.md
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
|
||||
# OpenID Connect
|
||||
|
||||
本文介绍了一个示例 OpenID Connect 插件配置,用于使用与 Okta , Auth0 , keycloak 身份提供程序对浏览器客户端进行身份验证。
|
||||
|
||||
## OpenID Connect with Okta
|
||||
### 配置 okta 账户
|
||||
* 登录到开发人员 Okta 网站 [Developer Okta site](https://developer.okta.com/)
|
||||
* 注册测试 web 应用程序
|
||||
|
||||
### 将测试 okta 应用程序与 Higress 关联
|
||||
* 创建服务来源
|
||||

|
||||
* 查看服务列表,有即成功
|
||||

|
||||
### 将测试 okta 应用程序与您的 Oidc-Wasm 插件关联
|
||||
* 创建访问 okta 的 ingress
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: example-ingress
|
||||
annotations:
|
||||
higress.io/destination: okta.dns
|
||||
higress.io/backend-protocol: "HTTPS"
|
||||
higress.io/ignore-path-case: "false"
|
||||
spec:
|
||||
ingressClassName: higress
|
||||
rules:
|
||||
- host: foo.bar.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
resource:
|
||||
apiGroup: networking.higress.io
|
||||
kind: McpBridge
|
||||
name: default
|
||||
|
||||
```
|
||||
* 配置 oidc 插件
|
||||
```yaml
|
||||
issuer: "https://dev-65874123.okta.com"
|
||||
redirect_url: "http://foo.bar.com/a/oauth2/callback"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
client_url: "http://foo.bar.com/a"
|
||||
cookie_domain: "foo.bar.com"
|
||||
client_id: "xxxx"
|
||||
client_secret: "xxxxx"
|
||||
service_domain: "dev-65874123.okta.com"
|
||||
service_name: "okta"
|
||||
service_port: 443
|
||||
service_source: "dns"
|
||||
timeout_millis: 2000
|
||||
```
|
||||
### 访问服务页面,未登陆的话进行跳转
|
||||

|
||||
### 登陆成功跳转到服务页面
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## OpenID Connect with auth0
|
||||
### 配置 auth0 账户
|
||||
* 登录到开发人员 Okta 网站 [Developer Auth0 site](https://auth0.com/)
|
||||
* 注册测试 web 应用程序
|
||||
|
||||
### 将测试 auth0 应用程序与 Higress 关联
|
||||
* 创建服务来源
|
||||

|
||||
|
||||
### 将测试 auth0 应用程序与您的 Oidc-Wasm 插件关联
|
||||
* 创建访问 auth0 的 ingress
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: example-ingress
|
||||
annotations:
|
||||
higress.io/destination: auth.dns
|
||||
higress.io/backend-protocol: "HTTPS"
|
||||
higress.io/ignore-path-case: "false"
|
||||
spec:
|
||||
ingressClassName: higress
|
||||
rules:
|
||||
- host: foo.bar.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
resource:
|
||||
apiGroup: networking.higress.io
|
||||
kind: McpBridge
|
||||
name: default
|
||||
|
||||
|
||||
```
|
||||
* 配置 oidc 插件
|
||||
```yaml
|
||||
CookieName: "_oauth2_wasm_c"
|
||||
client_id: "xxxxx"
|
||||
client_secret: "xxxxxx"
|
||||
cookie_domain: "foo.bar.com"
|
||||
cookie_path: "/b"
|
||||
client_url: "http://foo.bar.com/b"
|
||||
service_domain: "dev-650jsqsvuyrk4ahg.us.auth0.com"
|
||||
issuer: "https://dev-650jsqsvuyrk4ahg.us.auth0.com/"
|
||||
redirect_url: "http://foo.bar.com/b/oauth2/callback"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
service_name: "auth"
|
||||
service_port: 443
|
||||
service_source: "dns"
|
||||
timeout_millis: 2000
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 访问服务页面,未登陆的话进行跳转
|
||||

|
||||
|
||||
### 登陆成功跳转到服务页面
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## OpenID Connect with keyclocak
|
||||
### 配置 keyclocak 账户
|
||||
* 本文档采用 docker 本机进行部署,所以注册的 ip 应该采用 ifconfig 获取网卡 ip
|
||||

|
||||
|
||||
* 注册测试 web 应用程序
|
||||
|
||||
### 将测试 keyclocak 应用程序与 Higress 关联
|
||||
* 创建服务来源
|
||||
|
||||

|
||||
### 将测试 keyclocak 应用程序与您的 Oidc-Wasm 插件关联
|
||||
* 配置 oidc 插件
|
||||
```yaml
|
||||
issuer: "http://127.0.0.1:9090/realms/myrealm"
|
||||
redirect_url: "http://foo.bar.com/bar/oauth2/callback"
|
||||
client_url: "http://foo.bar.com/"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "email"
|
||||
cookie_name: "_oauth2_wasm_keyclocak"
|
||||
cookie_domain: "foo.bar.com"
|
||||
client_id: "myclinet"
|
||||
client_secret: "EdKdKBX4N0jtYuPD4aGxZWiI7EVh4pr9"
|
||||
service_host: "127.0.0.1:9090"
|
||||
service_name: "keyclocak"
|
||||
service_port: 80
|
||||
service_source: "ip"
|
||||
```
|
||||
|
||||
|
||||
### 访问服务页面,未登陆的话进行跳转
|
||||

|
||||
### 登陆成功跳转到服务页面
|
||||

|
||||
|
||||
|
||||
## 与oauth2-proxy支持的服务对比
|
||||
| 服务 | 是否支持 | |
|
||||
| ----------------------- | ----------------- | ---------------------------------------- |
|
||||
| Auth0 | 支持 | |
|
||||
| Okta | 支持 | |
|
||||
| dex | 支持 | |
|
||||
| Keycloak | 支持 | |
|
||||
| Gitea | 支持 | |
|
||||
| GitLab | 支持 | |
|
||||
| Google | 不支持 | 域名不一致 |
|
||||
| GitHub | 不支持 | 域名不一致 |
|
||||
| Microsoft Azure AD | 不支持 | |
|
||||
| Azure | 不支持 | |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 主要的差异
|
||||
| 主要功能差异 | OAuth2-Proxy | OIDC-Wasm |
|
||||
|----------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 将服务放置在 OAuth2-Proxy 后 | ✓ | 不具备直接验证的能力 |
|
||||
| 在当前层可以展示具体信息,如 email 等 | ✓ | 作为网关的插件,校验 token 的正确性后只是进行了转发。在实现的过程中已经捕捉到了 ID Token 信息,可以实现提取出具体的信息用于优化日志展示等。 |
|
||||
| 校验一些非标准的 issuer,如启动 skipIssuerCheck 的 Github | ✓ | 已经抽象出 OIDCHandler,开启 skipIssuerChecker。只要实现 OIDCHandler 的能力,可以在 ProcessRedirect 中指定 authURL 的校验,在 ProcessExchangeToken 中指定 TokenURL 获取 token,在 ProcessVerify 中校验 token |
|
||||
BIN
plugins/wasm-go/extensions/oidc/doc/auth0_0.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/auth0_1.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/keycloak_0.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/keycloak_1.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/keycloak_2.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/keycloak_3.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/oath_2.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/okta_1.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/okta_2.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/okta_3.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
plugins/wasm-go/extensions/oidc/doc/okta_4.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
27
plugins/wasm-go/extensions/oidc/go.mod
Normal file
@@ -0,0 +1,27 @@
|
||||
module oidc
|
||||
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230807053545-d307d0e755f1
|
||||
github.com/go-jose/go-jose/v3 v3.0.0
|
||||
github.com/tetratelabs/proxy-wasm-go-sdk v0.19.1-0.20220822060051-f9d179a57f8c
|
||||
github.com/tidwall/gjson v1.14.3
|
||||
golang.org/x/oauth2 v0.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wasilibs/nottinygc v0.4.0 // indirect
|
||||
golang.org/x/crypto v0.12.0 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
43
plugins/wasm-go/extensions/oidc/go.sum
Normal file
@@ -0,0 +1,43 @@
|
||||
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230807053545-d307d0e755f1/go.mod h1:KtgFT8mWxKNOWu0Oi+3gyKueq4Cj/fx+YsVh1kJLNRU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tetratelabs/proxy-wasm-go-sdk v0.19.1-0.20220822060051-f9d179a57f8c/go.mod h1:5t/pWFNJ9eMyu/K/Z+OeGhDJ9sN9eCo8fc2pyM/Qjg4=
|
||||
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
261
plugins/wasm-go/extensions/oidc/main.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"oidc/oc"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/tidwall/gjson"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const OAUTH2CALLBACK = "oauth2/callback"
|
||||
|
||||
type OidcConfig struct {
|
||||
Issuer string
|
||||
Path string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
ClientURL string
|
||||
Timeout int
|
||||
CookieName string
|
||||
CookieSecret string
|
||||
CookieDomain string
|
||||
CookiePath string
|
||||
CookieSameSite string
|
||||
CookieSecure bool
|
||||
CookieHTTPOnly bool
|
||||
Scopes []string
|
||||
SkipExpiryCheck bool
|
||||
SkipNonceCheck bool
|
||||
Client wrapper.HttpClient
|
||||
}
|
||||
|
||||
func main() {
|
||||
wrapper.SetCtx(
|
||||
"oidc",
|
||||
wrapper.ParseConfigBy(parseConfig),
|
||||
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
|
||||
)
|
||||
}
|
||||
|
||||
func parseConfig(json gjson.Result, config *OidcConfig, log wrapper.Log) error {
|
||||
config.Issuer = json.Get("issuer").String()
|
||||
if config.Issuer == "" {
|
||||
return errors.New("missing issuer in config")
|
||||
}
|
||||
|
||||
config.ClientID = json.Get("client_id").String()
|
||||
if config.ClientID == "" {
|
||||
return errors.New("missing client_id in config")
|
||||
}
|
||||
|
||||
config.ClientSecret = json.Get("client_secret").String()
|
||||
if config.ClientSecret == "" {
|
||||
return errors.New("missing client_secret in config")
|
||||
}
|
||||
config.ClientURL = json.Get("client_url").String()
|
||||
_, err := url.ParseRequestURI(config.ClientURL)
|
||||
if err != nil {
|
||||
return errors.New("missing client_url in config or err format")
|
||||
}
|
||||
|
||||
err = oc.IsValidRedirect(json.Get("redirect_url").String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config.RedirectURL = json.Get("redirect_url").String()
|
||||
|
||||
config.SkipExpiryCheck = json.Get("skip_expiry_check").Bool()
|
||||
config.SkipNonceCheck = json.Get("skip_nonce_check").Bool()
|
||||
for _, item := range json.Get("scopes").Array() {
|
||||
scopes := item.String()
|
||||
config.Scopes = append(config.Scopes, scopes)
|
||||
}
|
||||
parsedURL, err := url.Parse(config.Issuer)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse issuer URL")
|
||||
}
|
||||
config.Path = parsedURL.Path
|
||||
|
||||
timeout := json.Get("timeout_millis").Int()
|
||||
if timeout <= 0 {
|
||||
config.Timeout = 500
|
||||
} else {
|
||||
config.Timeout = int(timeout)
|
||||
}
|
||||
|
||||
//cookie
|
||||
|
||||
config.CookieSecret = oc.Set32Bytes(config.ClientSecret)
|
||||
config.CookieName = json.Get("cookie_name").String()
|
||||
if config.CookieName == "" {
|
||||
config.CookieName = "_oidc_wasm"
|
||||
}
|
||||
config.CookieDomain = json.Get("cookie_domain").String()
|
||||
if config.CookieDomain == "" {
|
||||
return errors.New("missing cookie_domain in config or err format")
|
||||
}
|
||||
config.CookiePath = json.Get("cookie_path").String()
|
||||
if config.CookiePath == "" {
|
||||
config.CookiePath = "/"
|
||||
}
|
||||
config.CookieSecure = json.Get("cookie_secure").Bool()
|
||||
config.CookieSecure = json.Get("cookie_httponly").Bool()
|
||||
|
||||
config.CookieSameSite = json.Get("cookie_samesite").String()
|
||||
if config.CookieSameSite == "" {
|
||||
config.CookieSameSite = "Lax"
|
||||
}
|
||||
|
||||
serviceSource := json.Get("service_source").String()
|
||||
serviceName := json.Get("service_name").String()
|
||||
servicePort := json.Get("service_port").Int()
|
||||
serviceHost := json.Get("service_host").String()
|
||||
if serviceName == "" || servicePort == 0 {
|
||||
return errors.New("invalid service config")
|
||||
}
|
||||
switch serviceSource {
|
||||
case "ip":
|
||||
config.Client = wrapper.NewClusterClient(&wrapper.StaticIpCluster{
|
||||
ServiceName: serviceName,
|
||||
Host: serviceHost,
|
||||
Port: servicePort,
|
||||
})
|
||||
log.Debugf("%v %v %v", serviceName, serviceHost, servicePort)
|
||||
return nil
|
||||
case "dns":
|
||||
domain := json.Get("service_domain").String()
|
||||
if domain == "" {
|
||||
return errors.New("missing service_domain in config")
|
||||
}
|
||||
config.Client = wrapper.NewClusterClient(&wrapper.DnsCluster{
|
||||
ServiceName: serviceName,
|
||||
Port: servicePort,
|
||||
Domain: domain,
|
||||
})
|
||||
return nil
|
||||
default:
|
||||
return errors.New("unknown service source: " + serviceSource)
|
||||
}
|
||||
}
|
||||
|
||||
func onHttpRequestHeaders(ctx wrapper.HttpContext, config OidcConfig, log wrapper.Log) types.Action {
|
||||
|
||||
defaultHandler := oc.NewDefaultOAuthHandler()
|
||||
cookieString, _ := proxywasm.GetHttpRequestHeader("cookie")
|
||||
oidcCookieValue, code, state, err := oc.GetParams(config.CookieName, cookieString, ctx.Path(), config.CookieSecret)
|
||||
if err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("GetParams err : %v", err), http.StatusBadRequest)
|
||||
return types.ActionContinue
|
||||
}
|
||||
nonce, _ := oc.Nonce(32)
|
||||
nonceStr := oc.GenState(nonce, config.ClientSecret, config.RedirectURL)
|
||||
createdAtTime := time.Now()
|
||||
cfg := &oc.Oatuh2Config{
|
||||
Config: oauth2.Config{
|
||||
ClientID: config.ClientID,
|
||||
ClientSecret: config.ClientSecret,
|
||||
RedirectURL: config.RedirectURL,
|
||||
Scopes: config.Scopes,
|
||||
},
|
||||
Issuer: config.Issuer,
|
||||
ClientUrl: config.ClientURL,
|
||||
Path: config.Path,
|
||||
SkipExpiryCheck: config.SkipExpiryCheck,
|
||||
Timeout: config.Timeout,
|
||||
Client: config.Client,
|
||||
SkipNonceCheck: config.SkipNonceCheck,
|
||||
Option: &oc.OidcOption{},
|
||||
CookieOption: &oc.CookieOption{
|
||||
Name: config.CookieName,
|
||||
Domain: config.CookieDomain,
|
||||
Secret: config.CookieSecret,
|
||||
Path: config.CookiePath,
|
||||
SameSite: config.CookieSameSite,
|
||||
Secure: config.CookieSecure,
|
||||
HTTPOnly: config.CookieHTTPOnly,
|
||||
},
|
||||
CookieData: &oc.CookieData{
|
||||
Nonce: []byte(nonceStr),
|
||||
CreatedAt: createdAtTime,
|
||||
},
|
||||
}
|
||||
log.Debugf("path :%v host :%v state :%v code :%v cookie :%v", ctx.Path(), ctx.Host(), state, code, oidcCookieValue)
|
||||
|
||||
if oidcCookieValue == "" {
|
||||
if code == "" {
|
||||
if err := defaultHandler.ProcessRedirect(&log, cfg); err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("ProcessRedirect error : %v", err), http.StatusInternalServerError)
|
||||
return types.ActionContinue
|
||||
}
|
||||
return types.ActionPause
|
||||
}
|
||||
if strings.Contains(ctx.Path(), OAUTH2CALLBACK) {
|
||||
parts := strings.Split(state, ".")
|
||||
if len(parts) != 2 {
|
||||
oc.SendError(&log, "State signature verification failed", http.StatusUnauthorized)
|
||||
return types.ActionContinue
|
||||
}
|
||||
stateVal, signature := parts[0], parts[1]
|
||||
if err := oc.VerifyState(stateVal, signature, cfg.ClientSecret, cfg.RedirectURL); err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("State signature verification failed : %v", err), http.StatusUnauthorized)
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
cfg.Option.Code = code
|
||||
cfg.Option.Mod = oc.SenBack
|
||||
if err := defaultHandler.ProcessExchangeToken(&log, cfg); err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("ProcessExchangeToken error : %v", err), http.StatusInternalServerError)
|
||||
return types.ActionContinue
|
||||
}
|
||||
return types.ActionPause
|
||||
}
|
||||
oc.SendError(&log, fmt.Sprintf("redirect URL must end with oauth2/callback"), http.StatusBadRequest)
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
cookiedata, err := oc.DeserializedeCookieData(oidcCookieValue)
|
||||
if err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("DeserializedeCookieData err : %v", err), http.StatusInternalServerError)
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
cfg.CookieData = &oc.CookieData{
|
||||
IDToken: cookiedata.IDToken,
|
||||
Secret: cfg.CookieOption.Secret,
|
||||
Nonce: cookiedata.Nonce,
|
||||
CreatedAt: cookiedata.CreatedAt,
|
||||
ExpiresOn: cookiedata.ExpiresOn,
|
||||
}
|
||||
cfg.Option.RawIdToken = cfg.CookieData.IDToken
|
||||
cfg.Option.Mod = oc.Access
|
||||
if err := defaultHandler.ProcessVerify(&log, cfg); err != nil {
|
||||
oc.SendError(&log, fmt.Sprintf("ProcessVerify error : %v", err), http.StatusUnauthorized)
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
return types.ActionPause
|
||||
}
|
||||
116
plugins/wasm-go/extensions/oidc/oc/config.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Accessmod int
|
||||
|
||||
const (
|
||||
Access Accessmod = 0
|
||||
SenBack Accessmod = 1
|
||||
)
|
||||
|
||||
var invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`)
|
||||
|
||||
type IDConfig struct {
|
||||
ClientID string
|
||||
SupportedSigningAlgs []string
|
||||
SkipExpiryCheck bool
|
||||
//SkipIssuerCheck 用于特殊情况,其中调用者希望推迟对签发者的验证。
|
||||
//当启用时,调用者必须独立验证令牌的签发者是否为已知的有效值。
|
||||
//
|
||||
//
|
||||
//不匹配的签发者通常指示客户端配置错误。如果不希望发生不匹配,请检查所提供的签发者URL是否正确,而不是启用这个选项。
|
||||
SkipIssuerCheck bool
|
||||
SkipNonceCheck bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type idToken struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience audience `json:"aud"`
|
||||
Expiry jsonTime `json:"exp"`
|
||||
IssuedAt jsonTime `json:"iat"`
|
||||
NotBefore *jsonTime `json:"nbf"`
|
||||
Nonce string `json:"nonce"`
|
||||
AtHash string `json:"at_hash"`
|
||||
ClaimNames map[string]string `json:"_claim_names"`
|
||||
ClaimSources map[string]claimSource `json:"_claim_sources"`
|
||||
}
|
||||
|
||||
type claimSource struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
type Oatuh2Config struct {
|
||||
oauth2.Config
|
||||
Issuer string
|
||||
JwksURL string
|
||||
ClientUrl string
|
||||
Path string
|
||||
SupportedSigningAlgs []string
|
||||
SkipExpiryCheck bool
|
||||
Timeout int
|
||||
Client wrapper.HttpClient
|
||||
SkipNonceCheck bool
|
||||
|
||||
Option *OidcOption
|
||||
CookieOption *CookieOption
|
||||
CookieData *CookieData
|
||||
}
|
||||
|
||||
type OidcOption struct {
|
||||
StateStr string
|
||||
Nonce string
|
||||
Code string
|
||||
Mod Accessmod
|
||||
RawIdToken string
|
||||
AuthStyle AuthStyle
|
||||
}
|
||||
|
||||
func IsValidRedirect(redirect string) error {
|
||||
if !strings.HasSuffix(redirect, "oauth2/callback") {
|
||||
return errors.New("redirect URL must end with oauth2/callback")
|
||||
}
|
||||
switch {
|
||||
case redirect == "":
|
||||
return errors.New("redirect URL is empty")
|
||||
case strings.HasPrefix(redirect, "/"):
|
||||
if strings.HasPrefix(redirect, "//") || invalidRedirectRegex.MatchString(redirect) {
|
||||
return errors.New("invalid local redirect URL")
|
||||
}
|
||||
return nil
|
||||
case strings.HasPrefix(redirect, "http://"), strings.HasPrefix(redirect, "https://"):
|
||||
_, err := url.ParseRequestURI(redirect)
|
||||
if err != nil {
|
||||
return errors.New("invalid remote redirect URL")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("redirect URL must start with /, http://, or https://")
|
||||
}
|
||||
}
|
||||
188
plugins/wasm-go/extensions/oidc/oc/cookie.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CookieData struct {
|
||||
IDToken string
|
||||
Secret string
|
||||
Nonce []byte
|
||||
CreatedAt time.Time
|
||||
ExpiresOn time.Time
|
||||
}
|
||||
|
||||
type CookieOption struct {
|
||||
Name string
|
||||
Domain string
|
||||
Secret string
|
||||
value string
|
||||
Path string
|
||||
SameSite string
|
||||
Expire time.Time
|
||||
Secure bool
|
||||
HTTPOnly bool
|
||||
}
|
||||
|
||||
// SerializeAndEncrypt 将 CookieData 对象序列化并加密为一个安全的cookie header
|
||||
func SerializeAndEncryptCookieData(data *CookieData, keySecret string, cookieSettings *CookieOption) (string, error) {
|
||||
return buildSecureCookieHeader(data, keySecret, cookieSettings)
|
||||
}
|
||||
|
||||
// DeserializedeCookieData 将一个安全的cookie header解密并反序列化为 CookieData 对象
|
||||
func DeserializedeCookieData(cookievalue string) (*CookieData, error) {
|
||||
|
||||
data, err := retrieveCookieData(cookievalue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if checkCookieExpiry(data) {
|
||||
return nil, fmt.Errorf("cookie is expired")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
func Set32Bytes(key string) string {
|
||||
const desiredLength = 32
|
||||
keyLength := len(key)
|
||||
|
||||
var adjustedKey string
|
||||
if keyLength > desiredLength {
|
||||
adjustedKey = key[:desiredLength]
|
||||
} else if keyLength < desiredLength {
|
||||
padding := strings.Repeat("0", desiredLength-keyLength)
|
||||
adjustedKey = key + padding
|
||||
} else {
|
||||
adjustedKey = key
|
||||
}
|
||||
return adjustedKey
|
||||
}
|
||||
|
||||
// 必须是16/24/32字节长
|
||||
func Decrypt(ciphertext string, key string) (string, error) {
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
decodedCiphertext, err := base64.URLEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(decodedCiphertext) < aes.BlockSize {
|
||||
return "", fmt.Errorf("ciphertext is too short")
|
||||
}
|
||||
|
||||
iv := decodedCiphertext[:aes.BlockSize]
|
||||
decodedCiphertext = decodedCiphertext[aes.BlockSize:]
|
||||
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
|
||||
stream.XORKeyStream(decodedCiphertext, decodedCiphertext)
|
||||
|
||||
return string(decodedCiphertext), nil
|
||||
}
|
||||
|
||||
func encrypt(plainText string, key string) (string, error) {
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := make([]byte, aes.BlockSize+len(plainText))
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext[aes.BlockSize:], []byte(plainText))
|
||||
|
||||
return base64.URLEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func buildSecureCookieHeader(data *CookieData, keySecret string, cookieSettings *CookieOption) (string, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encryptedValue, err := encrypt(string(jsonData), keySecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encodedValue := url.QueryEscape(encryptedValue)
|
||||
cookieSettings.value = encodedValue
|
||||
|
||||
return generateCookie(cookieSettings), nil
|
||||
}
|
||||
|
||||
func retrieveCookieData(cookieValue string) (*CookieData, error) {
|
||||
var data CookieData
|
||||
err := json.Unmarshal([]byte(cookieValue), &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func generateCookie(settings *CookieOption) string {
|
||||
var secureFlag, httpOnlyFlag, sameSiteFlag string
|
||||
if settings.Secure {
|
||||
secureFlag = "Secure;"
|
||||
}
|
||||
|
||||
if settings.HTTPOnly {
|
||||
httpOnlyFlag = "HttpOnly;"
|
||||
}
|
||||
|
||||
if settings.SameSite != "" {
|
||||
sameSiteFlag = fmt.Sprintf("SameSite=%s;", settings.SameSite)
|
||||
}
|
||||
|
||||
expiresStr := settings.Expire.Format(time.RFC1123)
|
||||
maxAge := int(settings.Expire.Sub(time.Now()).Seconds())
|
||||
|
||||
cookie := fmt.Sprintf("%s=%s; Path=%s; Domain=%s; Expires=%s; Max-Age=%d; %s %s %s",
|
||||
settings.Name,
|
||||
settings.value,
|
||||
settings.Path,
|
||||
settings.Domain,
|
||||
expiresStr,
|
||||
maxAge,
|
||||
secureFlag,
|
||||
httpOnlyFlag,
|
||||
sameSiteFlag,
|
||||
)
|
||||
return cookie
|
||||
}
|
||||
|
||||
func checkCookieExpiry(data *CookieData) bool {
|
||||
return data.ExpiresOn.Before(time.Now())
|
||||
}
|
||||
76
plugins/wasm-go/extensions/oidc/oc/encryption.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"golang.org/x/oauth2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Nonce(length int) ([]byte, error) {
|
||||
b := make([]byte, length)
|
||||
_, err := rand.Read(b)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func HashNonce(nonce []byte) string {
|
||||
hasher := sha256.New()
|
||||
hasher.Write(nonce)
|
||||
return base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func GenState(nonce []byte, key string, redirectUrl string) string {
|
||||
hashedNonce := HashNonce(nonce)
|
||||
encodedRedirectUrl := base64.RawURLEncoding.EncodeToString([]byte(redirectUrl))
|
||||
state := fmt.Sprintf("%s:%s", hashedNonce, encodedRedirectUrl)
|
||||
signature := SignState(state, key)
|
||||
return fmt.Sprintf("%s.%s", state, signature)
|
||||
}
|
||||
|
||||
func SignState(state string, key string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(state))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func VerifyState(state, signature, key, redirect string) error {
|
||||
if !hmac.Equal([]byte(signature), []byte(SignState(state, key))) {
|
||||
return fmt.Errorf("signature mismatch")
|
||||
}
|
||||
|
||||
parts := strings.Split(state, ":")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid state format")
|
||||
}
|
||||
|
||||
redirectUrl, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode redirect URL: %v", err)
|
||||
}
|
||||
if string(redirectUrl) != redirect {
|
||||
return fmt.Errorf("redirect URL mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetNonce(nonce string) oauth2.AuthCodeOption {
|
||||
return oauth2.SetAuthURLParam("nonce", nonce)
|
||||
}
|
||||
256
plugins/wasm-go/extensions/oidc/oc/exchange.go
Normal file
@@ -0,0 +1,256 @@
|
||||
//Copyright 2023 go-oidc
|
||||
//
|
||||
//Licensed under the Apache License, Version 2.0 (the "License");
|
||||
//you may not use this file except in compliance with the License.
|
||||
//You may obtain a copy of the License at
|
||||
//
|
||||
//http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
//Unless required by applicable law or agreed to in writing, software
|
||||
//distributed under the License is distributed on an "AS IS" BASIS,
|
||||
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//See the License for the specific language governing permissions and
|
||||
//limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/tidwall/gjson"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
TokenType string
|
||||
RefreshToken string
|
||||
Expiry time.Time
|
||||
Raw interface{}
|
||||
}
|
||||
|
||||
type tokenJSON struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn expirationTime `json:"expires_in"` // at least PayPal returns string, while most return number
|
||||
}
|
||||
|
||||
type AuthCodeOption interface {
|
||||
setValue(url.Values)
|
||||
}
|
||||
|
||||
type setParam struct{ k, v string }
|
||||
|
||||
func (p setParam) SetValue(m url.Values) { m.Set(p.k, p.v) }
|
||||
|
||||
func ReturnURL(RedirectURL, code string, opts ...AuthCodeOption) url.Values {
|
||||
v := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
}
|
||||
if RedirectURL != "" {
|
||||
v.Set("redirect_uri", RedirectURL)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.setValue(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TokenFromInternal(t *Token) *Token {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return &Token{
|
||||
AccessToken: t.AccessToken,
|
||||
TokenType: t.TokenType,
|
||||
RefreshToken: t.RefreshToken,
|
||||
Expiry: t.Expiry,
|
||||
Raw: t.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *tokenJSON) expiry() (t time.Time) {
|
||||
if v := e.ExpiresIn; v != 0 {
|
||||
return time.Now().Add(time.Duration(v) * time.Second)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type expirationTime int32
|
||||
|
||||
func (e *expirationTime) UnmarshalJSON(b []byte) error {
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
var n json.Number
|
||||
err := json.Unmarshal(b, &n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i, err := n.Int64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i > math.MaxInt32 {
|
||||
i = math.MaxInt32
|
||||
}
|
||||
*e = expirationTime(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthStyle int
|
||||
|
||||
const (
|
||||
AuthStyleUnknown AuthStyle = 0
|
||||
AuthStyleInParams AuthStyle = 1
|
||||
AuthStyleInHeader AuthStyle = 2
|
||||
)
|
||||
|
||||
var authStyleCache struct {
|
||||
m map[string]AuthStyle // keyed by tokenURL
|
||||
}
|
||||
|
||||
func LookupAuthStyle(tokenURL string) (style AuthStyle, ok bool) {
|
||||
style, ok = authStyleCache.m[tokenURL]
|
||||
return
|
||||
}
|
||||
|
||||
// SetAuthStyle adds an entry to authStyleCache, documented above.
|
||||
func SetAuthStyle(tokenURL string, v AuthStyle) {
|
||||
if authStyleCache.m == nil {
|
||||
authStyleCache.m = make(map[string]AuthStyle)
|
||||
}
|
||||
authStyleCache.m[tokenURL] = v
|
||||
}
|
||||
|
||||
func (t *Token) Extra(key string) interface{} {
|
||||
if raw, ok := t.Raw.(map[string]interface{}); ok {
|
||||
return raw[key]
|
||||
}
|
||||
|
||||
vals, ok := t.Raw.(url.Values)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := vals.Get(key)
|
||||
switch s := strings.TrimSpace(v); strings.Count(s, ".") {
|
||||
case 0: // Contains no "."; try to parse as int
|
||||
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return i
|
||||
}
|
||||
case 1: // Contains a single "."; try to parse as float
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func UnmarshalToken(token *Token, Headers http.Header, body []byte) (*Token, error) {
|
||||
if !gjson.ValidBytes(body) {
|
||||
return nil, fmt.Errorf("invalid JSON format in response body , get %v", string(body))
|
||||
}
|
||||
content, _, _ := mime.ParseMediaType(Headers.Get("Content-Type"))
|
||||
|
||||
switch content {
|
||||
case "application/x-www-form-urlencoded", "text/plain":
|
||||
vals, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token = &Token{
|
||||
AccessToken: vals.Get("access_token"),
|
||||
TokenType: vals.Get("token_type"),
|
||||
RefreshToken: vals.Get("refresh_token"),
|
||||
Raw: vals,
|
||||
}
|
||||
e := vals.Get("expires_in")
|
||||
expires, _ := strconv.Atoi(e)
|
||||
if expires != 0 {
|
||||
token.Expiry = time.Now().Add(time.Duration(expires) * time.Second)
|
||||
}
|
||||
default:
|
||||
var tj tokenJSON
|
||||
if err := json.Unmarshal(body, &tj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token = &Token{
|
||||
AccessToken: tj.AccessToken,
|
||||
TokenType: tj.TokenType,
|
||||
RefreshToken: tj.RefreshToken,
|
||||
Expiry: tj.expiry(),
|
||||
Raw: make(map[string]interface{}),
|
||||
}
|
||||
if err := json.Unmarshal(body, &token.Raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// no error checks for optional fields
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
return nil, errors.New("oauth2: server response missing access_token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func NewTokenRequest(tokenURL, clientID, clientSecret string, v url.Values, authStyle AuthStyle) ([][2]string, []byte, error) {
|
||||
if authStyle == AuthStyleInParams {
|
||||
v = cloneURLValues(v)
|
||||
if clientID != "" {
|
||||
v.Set("client_id", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
v.Set("client_secret", clientSecret)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode()))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if authStyle == AuthStyleInHeader {
|
||||
req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
|
||||
}
|
||||
var headerArray [][2]string
|
||||
for key, values := range req.Header {
|
||||
if len(values) > 0 {
|
||||
headerArray = append(headerArray, [2]string{key, values[0]})
|
||||
}
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return headerArray, bodyBytes, nil
|
||||
}
|
||||
|
||||
func cloneURLValues(v url.Values) url.Values {
|
||||
v2 := make(url.Values, len(v))
|
||||
for k, vv := range v {
|
||||
v2[k] = append([]string(nil), vv...)
|
||||
}
|
||||
return v2
|
||||
}
|
||||
|
||||
type RetrieveError struct {
|
||||
Response *http.Response
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (r *RetrieveError) Error() string {
|
||||
return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body)
|
||||
}
|
||||
628
plugins/wasm-go/extensions/oidc/oc/jwks.go
Normal file
@@ -0,0 +1,628 @@
|
||||
/*-
|
||||
* Copyright 2014 Square Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
jose "github.com/go-jose/go-jose/v3"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
RS256 = "RS256" // RSASSA-PKCS-v1.5 using SHA-256
|
||||
RS384 = "RS384" // RSASSA-PKCS-v1.5 using SHA-384
|
||||
RS512 = "RS512" // RSASSA-PKCS-v1.5 using SHA-512
|
||||
ES256 = "ES256" // ECDSA using P-256 and SHA-256
|
||||
ES384 = "ES384" // ECDSA using P-384 and SHA-384
|
||||
ES512 = "ES512" // ECDSA using P-521 and SHA-512
|
||||
PS256 = "PS256" // RSASSA-PSS using SHA256 and MGF1-SHA256
|
||||
PS384 = "PS384" // RSASSA-PSS using SHA384 and MGF1-SHA384
|
||||
PS512 = "PS512" // RSASSA-PSS using SHA512 and MGF1-SHA512
|
||||
EdDSA = "EdDSA" // Ed25519 using SHA-512
|
||||
)
|
||||
|
||||
var SupportedAlgorithms = map[string]bool{
|
||||
RS256: true,
|
||||
RS384: true,
|
||||
RS512: true,
|
||||
ES256: true,
|
||||
ES384: true,
|
||||
ES512: true,
|
||||
PS256: true,
|
||||
PS384: true,
|
||||
PS512: true,
|
||||
EdDSA: true,
|
||||
}
|
||||
|
||||
type rawJSONWebKey struct {
|
||||
Use string `json:"use,omitempty"`
|
||||
Kty string `json:"kty,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Crv string `json:"crv,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
K *byteBuffer `json:"k,omitempty"`
|
||||
X *byteBuffer `json:"x,omitempty"`
|
||||
Y *byteBuffer `json:"y,omitempty"`
|
||||
N *byteBuffer `json:"n,omitempty"`
|
||||
E *byteBuffer `json:"e,omitempty"`
|
||||
// -- Following fields are only used for private keys --
|
||||
// RSA uses D, P and Q, while ECDSA uses only D. Fields Dp, Dq, and Qi are
|
||||
// completely optional. Therefore for RSA/ECDSA, D != nil is a contract that
|
||||
// we have a private key whereas D == nil means we have only a public key.
|
||||
D *byteBuffer `json:"d,omitempty"`
|
||||
P *byteBuffer `json:"p,omitempty"`
|
||||
Q *byteBuffer `json:"q,omitempty"`
|
||||
Dp *byteBuffer `json:"dp,omitempty"`
|
||||
Dq *byteBuffer `json:"dq,omitempty"`
|
||||
Qi *byteBuffer `json:"qi,omitempty"`
|
||||
// Certificates
|
||||
X5c []string `json:"x5c,omitempty"`
|
||||
X5u string `json:"x5u,omitempty"`
|
||||
X5tSHA1 string `json:"x5t,omitempty"`
|
||||
X5tSHA256 string `json:"x5t#S256,omitempty"`
|
||||
}
|
||||
type JSONWebKey struct {
|
||||
// Cryptographic key, can be a symmetric or asymmetric key.
|
||||
Key interface{}
|
||||
// Key identifier, parsed from `kid` header.
|
||||
KeyID string
|
||||
// Key algorithm, parsed from `alg` header.
|
||||
Algorithm string
|
||||
// Key use, parsed from `use` header.
|
||||
Use string
|
||||
|
||||
// X.509 certificate chain, parsed from `x5c` header.
|
||||
Certificates []*x509.Certificate
|
||||
// X.509 certificate URL, parsed from `x5u` header.
|
||||
CertificatesURL *url.URL
|
||||
// X.509 certificate thumbprint (SHA-1), parsed from `x5t` header.
|
||||
CertificateThumbprintSHA1 []byte
|
||||
// X.509 certificate thumbprint (SHA-256), parsed from `x5t#S256` header.
|
||||
CertificateThumbprintSHA256 []byte
|
||||
}
|
||||
type byteBuffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func base64URLDecode(value string) ([]byte, error) {
|
||||
value = strings.TrimRight(value, "=")
|
||||
return base64.RawURLEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func newBuffer(data []byte) *byteBuffer {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
return &byteBuffer{
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func parseCertificateChain(chain []string) ([]*x509.Certificate, error) {
|
||||
|
||||
out := make([]*x509.Certificate, len(chain))
|
||||
for i, cert := range chain {
|
||||
raw, err := base64.StdEncoding.DecodeString(cert)
|
||||
if err != nil {
|
||||
var log wrapper.Log
|
||||
log.Errorf("base64.StdEncoding.DecodeString(cert) err :")
|
||||
return nil, err
|
||||
}
|
||||
out[i], err = x509.ParseCertificate(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (b byteBuffer) bigInt() *big.Int {
|
||||
return new(big.Int).SetBytes(b.data)
|
||||
}
|
||||
|
||||
func (b byteBuffer) toInt() int {
|
||||
return int(b.bigInt().Int64())
|
||||
}
|
||||
|
||||
func (key rawJSONWebKey) ecPublicKey() (*ecdsa.PublicKey, error) {
|
||||
var curve elliptic.Curve
|
||||
switch key.Crv {
|
||||
case "P-256":
|
||||
curve = elliptic.P256()
|
||||
case "P-384":
|
||||
curve = elliptic.P384()
|
||||
case "P-521":
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, fmt.Errorf("go-jose/go-jose: unsupported elliptic curve '%s'", key.Crv)
|
||||
}
|
||||
|
||||
if key.X == nil || key.Y == nil {
|
||||
return nil, errors.New("go-jose/go-jose: invalid EC key, missing x/y values")
|
||||
}
|
||||
|
||||
// The length of this octet string MUST be the full size of a coordinate for
|
||||
// the curve specified in the "crv" parameter.
|
||||
// https://tools.ietf.org/html/rfc7518#section-6.2.1.2
|
||||
if curveSize(curve) != len(key.X.data) {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC public key, wrong length for x")
|
||||
}
|
||||
|
||||
if curveSize(curve) != len(key.Y.data) {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC public key, wrong length for y")
|
||||
}
|
||||
|
||||
x := key.X.bigInt()
|
||||
y := key.Y.bigInt()
|
||||
|
||||
if !curve.IsOnCurve(x, y) {
|
||||
return nil, errors.New("go-jose/go-jose: invalid EC key, X/Y are not on declared curve")
|
||||
}
|
||||
|
||||
return &ecdsa.PublicKey{
|
||||
Curve: curve,
|
||||
X: x,
|
||||
Y: y,
|
||||
}, nil
|
||||
}
|
||||
func (key rawJSONWebKey) rsaPrivateKey() (*rsa.PrivateKey, error) {
|
||||
var missing []string
|
||||
switch {
|
||||
case key.N == nil:
|
||||
missing = append(missing, "N")
|
||||
case key.E == nil:
|
||||
missing = append(missing, "E")
|
||||
case key.D == nil:
|
||||
missing = append(missing, "D")
|
||||
case key.P == nil:
|
||||
missing = append(missing, "P")
|
||||
case key.Q == nil:
|
||||
missing = append(missing, "Q")
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid RSA private key, missing %s value(s)", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
rv := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
N: key.N.bigInt(),
|
||||
E: key.E.toInt(),
|
||||
},
|
||||
D: key.D.bigInt(),
|
||||
Primes: []*big.Int{
|
||||
key.P.bigInt(),
|
||||
key.Q.bigInt(),
|
||||
},
|
||||
}
|
||||
|
||||
if key.Dp != nil {
|
||||
rv.Precomputed.Dp = key.Dp.bigInt()
|
||||
}
|
||||
if key.Dq != nil {
|
||||
rv.Precomputed.Dq = key.Dq.bigInt()
|
||||
}
|
||||
if key.Qi != nil {
|
||||
rv.Precomputed.Qinv = key.Qi.bigInt()
|
||||
}
|
||||
|
||||
err := rv.Validate()
|
||||
return rv, err
|
||||
}
|
||||
|
||||
func (key rawJSONWebKey) rsaPublicKey() (*rsa.PublicKey, error) {
|
||||
if key.N == nil || key.E == nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid RSA key, missing n/e values")
|
||||
}
|
||||
|
||||
return &rsa.PublicKey{
|
||||
N: key.N.bigInt(),
|
||||
E: key.E.toInt(),
|
||||
}, nil
|
||||
}
|
||||
func (b *byteBuffer) bytes() []byte {
|
||||
// Handling nil here allows us to transparently handle nil slices when serializing.
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.data
|
||||
}
|
||||
func (key rawJSONWebKey) symmetricKey() ([]byte, error) {
|
||||
if key.K == nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid OCT (symmetric) key, missing k value")
|
||||
}
|
||||
return key.K.bytes(), nil
|
||||
}
|
||||
func (key rawJSONWebKey) edPrivateKey() (ed25519.PrivateKey, error) {
|
||||
var missing []string
|
||||
switch {
|
||||
case key.D == nil:
|
||||
missing = append(missing, "D")
|
||||
case key.X == nil:
|
||||
missing = append(missing, "X")
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid Ed25519 private key, missing %s value(s)", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
privateKey := make([]byte, ed25519.PrivateKeySize)
|
||||
copy(privateKey[0:32], key.D.bytes())
|
||||
copy(privateKey[32:], key.X.bytes())
|
||||
rv := ed25519.PrivateKey(privateKey)
|
||||
return rv, nil
|
||||
}
|
||||
func (key rawJSONWebKey) edPublicKey() (ed25519.PublicKey, error) {
|
||||
if key.X == nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid Ed key, missing x value")
|
||||
}
|
||||
publicKey := make([]byte, ed25519.PublicKeySize)
|
||||
copy(publicKey[0:32], key.X.bytes())
|
||||
rv := ed25519.PublicKey(publicKey)
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
func GenJswkey(parseBytes gjson.Result) (*jose.JSONWebKey, error) {
|
||||
var raw rawJSONWebKey
|
||||
var log wrapper.Log
|
||||
selClom(&raw, parseBytes)
|
||||
|
||||
//
|
||||
certs, err := parseCertificateChain(raw.X5c)
|
||||
if err != nil {
|
||||
log.Errorf("err : %v", err)
|
||||
}
|
||||
var key interface{}
|
||||
var certPub interface{}
|
||||
var keyPub interface{}
|
||||
|
||||
if len(certs) > 0 {
|
||||
// We need to check that leaf public key matches the key embedded in this
|
||||
// JWK, as required by the standard (see RFC 7517, Section 4.7). Otherwise
|
||||
// the JWK parsed could be semantically invalid. Technically, should also
|
||||
// check key usage fields and other extensions on the cert here, but the
|
||||
// standard doesn't exactly explain how they're supposed to map from the
|
||||
// JWK representation to the X.509 extensions.
|
||||
certPub = certs[0].PublicKey
|
||||
}
|
||||
|
||||
switch raw.Kty {
|
||||
case "EC":
|
||||
if raw.D != nil {
|
||||
key, err = raw.ecPrivateKey()
|
||||
if err == nil {
|
||||
keyPub = key.(*ecdsa.PrivateKey).Public()
|
||||
}
|
||||
} else {
|
||||
key, err = raw.ecPublicKey()
|
||||
keyPub = key
|
||||
}
|
||||
case "RSA":
|
||||
if raw.D != nil {
|
||||
key, err = raw.rsaPrivateKey()
|
||||
if err == nil {
|
||||
keyPub = key.(*rsa.PrivateKey).Public()
|
||||
}
|
||||
} else {
|
||||
key, err = raw.rsaPublicKey()
|
||||
keyPub = key
|
||||
}
|
||||
case "oct":
|
||||
if certPub != nil {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, found 'oct' (symmetric) key with cert chain")
|
||||
}
|
||||
key, err = raw.symmetricKey()
|
||||
case "OKP":
|
||||
if raw.Crv == "Ed25519" && raw.X != nil {
|
||||
if raw.D != nil {
|
||||
key, err = raw.edPrivateKey()
|
||||
if err == nil {
|
||||
keyPub = key.(ed25519.PrivateKey).Public()
|
||||
}
|
||||
} else {
|
||||
key, err = raw.edPublicKey()
|
||||
keyPub = key
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("go-jose/go-jose: unknown curve %s'", raw.Crv)
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("go-jose/go-jose: unknown json web key type '%s'", raw.Kty)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if certPub != nil && keyPub != nil {
|
||||
|
||||
if !reflect.DeepEqual(certPub, keyPub) {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, public keys in key and x5c fields do not match")
|
||||
}
|
||||
}
|
||||
|
||||
k := &jose.JSONWebKey{Key: key, KeyID: raw.Kid, Algorithm: raw.Alg, Use: raw.Use, Certificates: certs}
|
||||
|
||||
if raw.X5u != "" {
|
||||
k.CertificatesURL, err = url.Parse(raw.X5u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid JWK, x5u header is invalid URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// x5t parameters are base64url-encoded SHA thumbprints
|
||||
// See RFC 7517, Section 4.8, https://tools.ietf.org/html/rfc7517#section-4.8
|
||||
x5tSHA1bytes, err := base64URLDecode(raw.X5tSHA1)
|
||||
if err != nil {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5t header has invalid encoding")
|
||||
}
|
||||
|
||||
// RFC 7517, Section 4.8 is ambiguous as to whether the digest output should be byte or hex,
|
||||
// for this reason, after base64 decoding, if the size is sha1.Size it's likely that the value is a byte encoded
|
||||
// checksum so we skip this. Otherwise if the checksum was hex encoded we expect a 40 byte sized array so we'll
|
||||
// try to hex decode it. When Marshalling this value we'll always use a base64 encoded version of byte format checksum.
|
||||
if len(x5tSHA1bytes) == 2*sha1.Size {
|
||||
hx, err := hex.DecodeString(string(x5tSHA1bytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid JWK, unable to hex decode x5t: %v", err)
|
||||
|
||||
}
|
||||
x5tSHA1bytes = hx
|
||||
}
|
||||
|
||||
k.CertificateThumbprintSHA1 = x5tSHA1bytes
|
||||
|
||||
x5tSHA256bytes, err := base64URLDecode(raw.X5tSHA256)
|
||||
if err != nil {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5t#S256 header has invalid encoding")
|
||||
}
|
||||
|
||||
if len(x5tSHA256bytes) == 2*sha256.Size {
|
||||
hx256, err := hex.DecodeString(string(x5tSHA256bytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid JWK, unable to hex decode x5t#S256: %v", err)
|
||||
}
|
||||
x5tSHA256bytes = hx256
|
||||
}
|
||||
|
||||
k.CertificateThumbprintSHA256 = x5tSHA256bytes
|
||||
|
||||
x5tSHA1Len := len(k.CertificateThumbprintSHA1)
|
||||
x5tSHA256Len := len(k.CertificateThumbprintSHA256)
|
||||
if x5tSHA1Len > 0 && x5tSHA1Len != sha1.Size {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5t header is of incorrect size")
|
||||
}
|
||||
if x5tSHA256Len > 0 && x5tSHA256Len != sha256.Size {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5t#S256 header is of incorrect size")
|
||||
}
|
||||
|
||||
// If certificate chain *and* thumbprints are set, verify correctness.
|
||||
if len(k.Certificates) > 0 {
|
||||
leaf := k.Certificates[0]
|
||||
sha1sum := sha1.Sum(leaf.Raw)
|
||||
sha256sum := sha256.Sum256(leaf.Raw)
|
||||
|
||||
if len(k.CertificateThumbprintSHA1) > 0 && !bytes.Equal(sha1sum[:], k.CertificateThumbprintSHA1) {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5c thumbprint does not match x5t value")
|
||||
}
|
||||
|
||||
if len(k.CertificateThumbprintSHA256) > 0 && !bytes.Equal(sha256sum[:], k.CertificateThumbprintSHA256) {
|
||||
return nil, errors.New("go-jose/go-jose: invalid JWK, x5c thumbprint does not match x5t#S256 value")
|
||||
}
|
||||
}
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func curveSize(crv elliptic.Curve) int {
|
||||
bits := crv.Params().BitSize
|
||||
|
||||
div := bits / 8
|
||||
mod := bits % 8
|
||||
|
||||
if mod == 0 {
|
||||
return div
|
||||
}
|
||||
|
||||
return div + 1
|
||||
}
|
||||
func dSize(curve elliptic.Curve) int {
|
||||
order := curve.Params().P
|
||||
bitLen := order.BitLen()
|
||||
size := bitLen / 8
|
||||
if bitLen%8 != 0 {
|
||||
size++
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func (key rawJSONWebKey) ecPrivateKey() (*ecdsa.PrivateKey, error) {
|
||||
var curve elliptic.Curve
|
||||
switch key.Crv {
|
||||
case "P-256":
|
||||
curve = elliptic.P256()
|
||||
case "P-384":
|
||||
curve = elliptic.P384()
|
||||
case "P-521":
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, fmt.Errorf("go-jose/go-jose: unsupported elliptic curve '%s'", key.Crv)
|
||||
}
|
||||
|
||||
if key.X == nil || key.Y == nil || key.D == nil {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC private key, missing x/y/d values")
|
||||
}
|
||||
|
||||
// The length of this octet string MUST be the full size of a coordinate for
|
||||
// the curve specified in the "crv" parameter.
|
||||
// https://tools.ietf.org/html/rfc7518#section-6.2.1.2
|
||||
if curveSize(curve) != len(key.X.data) {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC private key, wrong length for x")
|
||||
}
|
||||
|
||||
if curveSize(curve) != len(key.Y.data) {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC private key, wrong length for y")
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7518#section-6.2.2.1
|
||||
if dSize(curve) != len(key.D.data) {
|
||||
return nil, fmt.Errorf("go-jose/go-jose: invalid EC private key, wrong length for d")
|
||||
}
|
||||
|
||||
x := key.X.bigInt()
|
||||
y := key.Y.bigInt()
|
||||
|
||||
if !curve.IsOnCurve(x, y) {
|
||||
return nil, errors.New("go-jose/go-jose: invalid EC key, X/Y are not on declared curve")
|
||||
}
|
||||
|
||||
return &ecdsa.PrivateKey{
|
||||
PublicKey: ecdsa.PublicKey{
|
||||
Curve: curve,
|
||||
X: x,
|
||||
Y: y,
|
||||
},
|
||||
D: key.D.bigInt(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func selClom(raw *rawJSONWebKey, parseBytes gjson.Result) {
|
||||
|
||||
raw.Use = parseBytes.Get("use").String()
|
||||
|
||||
raw.Kty = parseBytes.Get("kty").String()
|
||||
raw.Kid = parseBytes.Get("kid").String()
|
||||
raw.Crv = parseBytes.Get("crv").String()
|
||||
raw.Alg = parseBytes.Get("alg").String()
|
||||
|
||||
for _, item := range parseBytes.Get("x5c").Array() {
|
||||
scopes := item.String()
|
||||
raw.X5c = append(raw.X5c, scopes)
|
||||
}
|
||||
|
||||
raw.X5u = parseBytes.Get("x5u").String()
|
||||
raw.X5tSHA1 = parseBytes.Get("x5t").String()
|
||||
|
||||
raw.X5tSHA256 = parseBytes.Get("x5t#S256").String()
|
||||
|
||||
//k
|
||||
if k := parseBytes.Get("k").Exists(); k {
|
||||
decode, err := base64URLDecode(parseBytes.Get("k").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.K = newBuffer(decode)
|
||||
}
|
||||
//x
|
||||
if x := parseBytes.Get("x").Exists(); x {
|
||||
decode, err := base64URLDecode(parseBytes.Get("x").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.X = newBuffer(decode)
|
||||
}
|
||||
//y
|
||||
if y := parseBytes.Get("y").Exists(); y {
|
||||
decode, err := base64URLDecode(parseBytes.Get("y").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.Y = newBuffer(decode)
|
||||
}
|
||||
//n
|
||||
if n := parseBytes.Get("n").Exists(); n {
|
||||
decode, err := base64URLDecode(parseBytes.Get("n").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.N = newBuffer(decode)
|
||||
}
|
||||
//e
|
||||
if e := parseBytes.Get("e").Exists(); e {
|
||||
decode, err := base64URLDecode(parseBytes.Get("e").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.E = newBuffer(decode)
|
||||
}
|
||||
//d
|
||||
if d := parseBytes.Get("d").Exists(); d {
|
||||
decode, err := base64URLDecode(parseBytes.Get("d").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.D = newBuffer(decode)
|
||||
}
|
||||
//p
|
||||
if p := parseBytes.Get("p").Exists(); p {
|
||||
decode, err := base64URLDecode(parseBytes.Get("p").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.P = newBuffer(decode)
|
||||
}
|
||||
//q
|
||||
if q := parseBytes.Get("q").Exists(); q {
|
||||
decode, err := base64URLDecode(parseBytes.Get("q").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.Q = newBuffer(decode)
|
||||
}
|
||||
//dp
|
||||
if dp := parseBytes.Get("dp").Exists(); dp {
|
||||
decode, err := base64URLDecode(parseBytes.Get("dp").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.Dp = newBuffer(decode)
|
||||
|
||||
}
|
||||
//dq
|
||||
if dq := parseBytes.Get("dq").Exists(); dq {
|
||||
decode, err := base64URLDecode(parseBytes.Get("dq").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.Dq = newBuffer(decode)
|
||||
}
|
||||
//qi
|
||||
if qi := parseBytes.Get("qi").Exists(); qi {
|
||||
decode, err := base64URLDecode(parseBytes.Get("qi").String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw.Qi = newBuffer(decode)
|
||||
}
|
||||
|
||||
}
|
||||
289
plugins/wasm-go/extensions/oidc/oc/provider.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/tidwall/gjson"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var re = regexp.MustCompile("<[^>]*>")
|
||||
|
||||
// OidcHandler 定义了处理 OpenID Connect(OIDC)认证流程的方法集合。
|
||||
// OIDC 是一个基于 OAuth 2.0 协议的身份验证和授权协议。
|
||||
type OidcHandler interface {
|
||||
// ProcessRedirect 负责处理来自 OIDC 身份提供者的重定向响应。
|
||||
// 该方法会从openid-configuration中获取 authorization_endpoint,
|
||||
// 并确保其中的状态以及任何可能的错误代码都得到正确处理。
|
||||
ProcessRedirect(log *wrapper.Log, cfg *Oatuh2Config) error
|
||||
|
||||
// ProcessExchangeToken 负责执行令牌交换过程。
|
||||
// 该方法会从 openid-configuration 中获取 token_endpoint 和 jwks_uri,
|
||||
// 然后使用授权码来交换 access token 和 ID token。
|
||||
ProcessExchangeToken(log *wrapper.Log, cfg *Oatuh2Config) error
|
||||
|
||||
// ProcessVerify 负责验证 ID 令牌的有效性。
|
||||
// 通过使用 openid-configuration 中的获取的 jwks_uri 配置信息来验证 ID 令牌的签名和有效性。
|
||||
ProcessVerify(log *wrapper.Log, cfg *Oatuh2Config) error
|
||||
}
|
||||
type DefaultOAuthHandler struct {
|
||||
}
|
||||
|
||||
func NewDefaultOAuthHandler() OidcHandler {
|
||||
return &DefaultOAuthHandler{}
|
||||
}
|
||||
|
||||
func ProcessHTTPCall(log *wrapper.Log, cfg *Oatuh2Config, callback func(responseBody []byte)) error {
|
||||
wellKnownPath := strings.TrimSuffix(cfg.Path, "/") + "/.well-known/openid-configuration"
|
||||
if err := cfg.Client.Get(wellKnownPath, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
|
||||
if err := ValidateHTTPResponse(statusCode, responseHeaders, responseBody); err != nil {
|
||||
cleanedBody := re.ReplaceAllString(string(responseBody), "")
|
||||
SendError(log, fmt.Sprintf("ValidateHTTPResponse failed , status : %v err : %v err_info: %v ", statusCode, err, cleanedBody), statusCode)
|
||||
return
|
||||
}
|
||||
callback(responseBody)
|
||||
|
||||
}, uint32(cfg.Timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DefaultOAuthHandler) ProcessRedirect(log *wrapper.Log, cfg *Oatuh2Config) error {
|
||||
return ProcessHTTPCall(log, cfg, func(responseBody []byte) {
|
||||
state, _ := Nonce(32)
|
||||
statStr := GenState(state, cfg.ClientSecret, cfg.RedirectURL)
|
||||
cfg.Endpoint.AuthURL = gjson.ParseBytes(responseBody).Get("authorization_endpoint").String()
|
||||
if cfg.Endpoint.AuthURL == "" {
|
||||
SendError(log, "Missing 'authorization_endpoint' in the OpenID configuration response.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var opts oauth2.AuthCodeOption
|
||||
if !cfg.SkipNonceCheck {
|
||||
opts = SetNonce(string(cfg.CookieData.Nonce))
|
||||
}
|
||||
codeURL := cfg.AuthCodeURL(statStr, opts)
|
||||
proxywasm.SendHttpResponse(http.StatusFound, [][2]string{
|
||||
{"Location", codeURL},
|
||||
}, nil, -1)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DefaultOAuthHandler) ProcessExchangeToken(log *wrapper.Log, cfg *Oatuh2Config) error {
|
||||
return ProcessHTTPCall(log, cfg, func(responseBody []byte) {
|
||||
|
||||
PvRJson := gjson.ParseBytes(responseBody)
|
||||
cfg.Endpoint.TokenURL = PvRJson.Get("token_endpoint").String()
|
||||
if cfg.Endpoint.TokenURL == "" {
|
||||
SendError(log, "Missing 'token_endpoint' in the OpenID configuration response.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.JwksURL = PvRJson.Get("jwks_uri").String()
|
||||
if cfg.JwksURL == "" {
|
||||
SendError(log, "Missing 'jwks_uri' in the OpenID configuration response.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.Option.AuthStyle = AuthStyle(cfg.Endpoint.AuthStyle)
|
||||
|
||||
if err := processToken(log, cfg); err != nil {
|
||||
SendError(log, fmt.Sprintf("ProcessToken failed : err %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DefaultOAuthHandler) ProcessVerify(log *wrapper.Log, cfg *Oatuh2Config) error {
|
||||
return ProcessHTTPCall(log, cfg, func(responseBody []byte) {
|
||||
PvRJson := gjson.ParseBytes(responseBody)
|
||||
|
||||
cfg.JwksURL = PvRJson.Get("jwks_uri").String()
|
||||
if cfg.JwksURL == "" {
|
||||
SendError(log, "Missing 'token_endpoint' in the OpenID configuration response.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var algs []string
|
||||
for _, a := range PvRJson.Get("id_token_signing_alg_values_supported").Array() {
|
||||
if SupportedAlgorithms[a.String()] {
|
||||
algs = append(algs, a.String())
|
||||
}
|
||||
}
|
||||
cfg.SupportedSigningAlgs = algs
|
||||
if err := processTokenVerify(log, cfg); err != nil {
|
||||
SendError(log, fmt.Sprintf("failed to verify token: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func processToken(log *wrapper.Log, cfg *Oatuh2Config) error {
|
||||
parsedURL, err := url.Parse(cfg.Endpoint.TokenURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid TokenURL: %v", err)
|
||||
}
|
||||
|
||||
var token Token
|
||||
urlVales := ReturnURL(cfg.RedirectURL, cfg.Option.Code)
|
||||
needsAuthStyleProbe := cfg.Option.AuthStyle == AuthStyleUnknown
|
||||
if needsAuthStyleProbe {
|
||||
if style, ok := LookupAuthStyle(cfg.Endpoint.TokenURL); ok {
|
||||
cfg.Option.AuthStyle = style
|
||||
} else {
|
||||
cfg.Option.AuthStyle = AuthStyleInHeader
|
||||
}
|
||||
}
|
||||
|
||||
headers, body, err := NewTokenRequest(cfg.Endpoint.TokenURL, cfg.ClientID, cfg.ClientSecret, urlVales, cfg.Option.AuthStyle)
|
||||
cb := func(statusCode int, responseHeaders http.Header, responseBody []byte) {
|
||||
if err := ValidateHTTPResponse(statusCode, responseHeaders, responseBody); err != nil {
|
||||
cleanedBody := re.ReplaceAllString(string(responseBody), "")
|
||||
SendError(log, fmt.Sprintf("Valid failed , status : %v err : %v err_info: %v ", statusCode, err, cleanedBody), statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
tk, err := UnmarshalToken(&token, responseHeaders, responseBody)
|
||||
if err != nil {
|
||||
SendError(log, fmt.Sprintf("UnmarshalToken error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tk != nil && token.RefreshToken == "" {
|
||||
token.RefreshToken = urlVales.Get("refresh_token")
|
||||
}
|
||||
|
||||
betoken := TokenFromInternal(tk)
|
||||
|
||||
rawIDToken, ok := betoken.Extra("id_token").(string)
|
||||
if !ok {
|
||||
SendError(log, fmt.Sprintf("No id_token field in oauth2 token."), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.Option.RawIdToken = rawIDToken
|
||||
|
||||
err = processTokenVerify(log, cfg)
|
||||
if err != nil {
|
||||
SendError(log, fmt.Sprintf("failed to verify token: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
err = cfg.Client.Post(parsedURL.Path, headers, body, cb, uint32(cfg.Timeout))
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP POST error: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processTokenVerify(log *wrapper.Log, cfg *Oatuh2Config) error {
|
||||
keySet := jose.JSONWebKeySet{}
|
||||
idTokenVerify := cfg.Verifier(&IDConfig{
|
||||
ClientID: cfg.ClientID,
|
||||
SupportedSigningAlgs: cfg.SupportedSigningAlgs,
|
||||
SkipExpiryCheck: cfg.SkipExpiryCheck,
|
||||
SkipNonceCheck: cfg.SkipNonceCheck,
|
||||
})
|
||||
|
||||
defaultHandlerForRedirect := NewDefaultOAuthHandler()
|
||||
parsedURL, err := url.Parse(cfg.JwksURL)
|
||||
if err != nil {
|
||||
log.Errorf("JwksURL is invalid err : %v", err)
|
||||
return err
|
||||
}
|
||||
cb := func(statusCode int, responseHeaders http.Header, responseBody []byte) {
|
||||
if err := ValidateHTTPResponse(statusCode, responseHeaders, responseBody); err != nil {
|
||||
cleanedBody := re.ReplaceAllString(string(responseBody), "")
|
||||
SendError(log, fmt.Sprintf("Valid failed , status : %v err : %v err_info: %v ", statusCode, err, cleanedBody), statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
res := gjson.ParseBytes(responseBody)
|
||||
for _, val := range res.Get("keys").Array() {
|
||||
jsw, err := GenJswkey(val)
|
||||
if err != nil {
|
||||
log.Errorf("err: %v", err)
|
||||
SendError(log, fmt.Sprintf("GenJswkey error:%v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
keySet.Keys = append(keySet.Keys, *jsw)
|
||||
}
|
||||
idtoken, err := idTokenVerify.VerifyToken(cfg.Option.RawIdToken, keySet)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("VerifyToken err : %v ", err)
|
||||
defaultHandlerForRedirect.ProcessRedirect(log, cfg)
|
||||
return
|
||||
}
|
||||
if !cfg.SkipNonceCheck && Access == cfg.Option.Mod {
|
||||
err := verifyNonce(idtoken, cfg)
|
||||
if err != nil {
|
||||
log.Error("VerifyNonce failed")
|
||||
defaultHandlerForRedirect.ProcessRedirect(log, cfg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
//回发和放行
|
||||
if cfg.Option.Mod == Access {
|
||||
proxywasm.AddHttpRequestHeader("Authorization", "Bearer "+cfg.Option.RawIdToken)
|
||||
proxywasm.ResumeHttpRequest()
|
||||
return
|
||||
}
|
||||
|
||||
cfg.CookieOption.Expire = idtoken.Expiry
|
||||
cfg.CookieData.IDToken = cfg.Option.RawIdToken
|
||||
cfg.CookieData.ExpiresOn = idtoken.Expiry
|
||||
cfg.CookieData.Secret = cfg.CookieOption.Secret
|
||||
|
||||
cookieHeader, err := SerializeAndEncryptCookieData(cfg.CookieData, cfg.CookieOption.Secret, cfg.CookieOption)
|
||||
if err != nil {
|
||||
SendError(log, fmt.Sprintf("SerializeAndEncryptCookieData failed : %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
proxywasm.SendHttpResponse(http.StatusFound, [][2]string{
|
||||
{"Location", cfg.ClientUrl},
|
||||
{"Set-Cookie", cookieHeader},
|
||||
}, nil, -1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := cfg.Client.Get(parsedURL.Path, nil, cb, uint32(cfg.Timeout)); err != nil {
|
||||
log.Errorf("client.Get error: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyNonce(idtoken *IDToken, cfg *Oatuh2Config) error {
|
||||
parts := strings.Split(idtoken.Nonce, ".")
|
||||
if len(parts) != 2 {
|
||||
return errors.New("nonce format err expect 2 parts")
|
||||
}
|
||||
stateval, signature := parts[0], parts[1]
|
||||
return VerifyState(stateval, signature, cfg.ClientSecret, cfg.RedirectURL)
|
||||
}
|
||||
112
plugins/wasm-go/extensions/oidc/oc/util.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package oc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ValidateHTTPResponse(statusCode int, headers http.Header, body []byte) error {
|
||||
contentType := headers.Get("Content-Type")
|
||||
if statusCode != http.StatusOK {
|
||||
return errors.New("call failed with status code")
|
||||
}
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
return fmt.Errorf("expected Content-Type = application/json , but got %s", contentType)
|
||||
}
|
||||
if !gjson.ValidBytes(body) {
|
||||
return errors.New("invalid JSON format in response body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetParams 返回顺序 cookie code state
|
||||
func GetParams(name, cookie, path, key string) (oidcCookieValue, code, state string, err error) {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
query := u.Query()
|
||||
code, state = query.Get("code"), query.Get("state")
|
||||
|
||||
cookiePairs := strings.Split(cookie, "; ")
|
||||
for _, pair := range cookiePairs {
|
||||
keyValue := strings.Split(pair, "=")
|
||||
if keyValue[0] == name {
|
||||
oidcCookieValue = keyValue[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
oidcCookieValue, err = url.QueryUnescape(oidcCookieValue)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
oidcCookieValue, err = Decrypt(oidcCookieValue, key)
|
||||
return oidcCookieValue, code, state, nil
|
||||
}
|
||||
|
||||
func SendError(log *wrapper.Log, errMsg string, status int) {
|
||||
log.Errorf(errMsg)
|
||||
proxywasm.SendHttpResponse(uint32(status), nil, []byte(errMsg), -1)
|
||||
}
|
||||
|
||||
type jsonTime time.Time
|
||||
|
||||
func (j *jsonTime) UnmarshalJSON(b []byte) error {
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
var unix int64
|
||||
|
||||
if t, err := n.Int64(); err == nil {
|
||||
unix = t
|
||||
} else {
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unix = int64(f)
|
||||
}
|
||||
*j = jsonTime(time.Unix(unix, 0))
|
||||
return nil
|
||||
}
|
||||
|
||||
type audience []string
|
||||
|
||||
func (a *audience) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
*a = audience{s}
|
||||
return nil
|
||||
}
|
||||
var auds []string
|
||||
if err := json.Unmarshal(b, &auds); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = auds
|
||||
return nil
|
||||
}
|
||||
219
plugins/wasm-go/extensions/oidc/oc/verifer.go
Normal file
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
Copyright 2023 go-oidc
|
||||
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package oc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
)
|
||||
|
||||
// IDTokenVerifierConfig
|
||||
type IDTokenVerifier struct {
|
||||
config *IDConfig
|
||||
issuer string
|
||||
}
|
||||
|
||||
const (
|
||||
issuerGoogleAccounts = "https://accounts.google.com"
|
||||
issuerGoogleAccountsNoScheme = "accounts.google.com"
|
||||
|
||||
LEEWAY = 5 * time.Minute
|
||||
)
|
||||
|
||||
type IDToken struct {
|
||||
Issuer string
|
||||
Audience []string
|
||||
Subject string
|
||||
Expiry time.Time
|
||||
IssuedAt time.Time
|
||||
Nonce string
|
||||
AccessTokenHash string
|
||||
sigAlgorithm string
|
||||
claims []byte
|
||||
distributedClaims map[string]claimSource
|
||||
}
|
||||
type TokenExpiredError struct {
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
func (e *TokenExpiredError) Error() string {
|
||||
return fmt.Sprintf("oidc: token is expired (Token Expiry: %v)", e.Expiry)
|
||||
}
|
||||
func (i *IDToken) Claims(v interface{}) error {
|
||||
if i.claims == nil {
|
||||
return errors.New("oidc: claims not set")
|
||||
}
|
||||
return json.Unmarshal(i.claims, v)
|
||||
}
|
||||
|
||||
func (v *IDTokenVerifier) VerifyToken(rawIDToken string, keySet jose.JSONWebKeySet) (*IDToken, error) {
|
||||
var log wrapper.Log
|
||||
payload, err := parseJWT(rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(" malformed jwt: %v", err)
|
||||
}
|
||||
var token idToken
|
||||
if err := json.Unmarshal(payload, &token); err != nil {
|
||||
log.Errorf("idToken Unmarshal error : %v ", err)
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
distributedClaims := make(map[string]claimSource)
|
||||
|
||||
//step through the token to map claim names to claim sources
|
||||
for cn, src := range token.ClaimNames {
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("failed to obtain source from claim name")
|
||||
}
|
||||
s, ok := token.ClaimSources[src]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source does not exist")
|
||||
}
|
||||
distributedClaims[cn] = s
|
||||
}
|
||||
|
||||
t := &IDToken{
|
||||
Issuer: token.Issuer,
|
||||
Subject: token.Subject,
|
||||
Audience: []string(token.Audience),
|
||||
Expiry: time.Time(token.Expiry),
|
||||
IssuedAt: time.Time(token.IssuedAt),
|
||||
Nonce: token.Nonce,
|
||||
AccessTokenHash: token.AtHash,
|
||||
claims: payload,
|
||||
distributedClaims: distributedClaims,
|
||||
}
|
||||
|
||||
// Check issuer.
|
||||
if !v.config.SkipIssuerCheck && t.Issuer != v.issuer {
|
||||
// Google sometimes returns "accounts.google.com" as the issuer claim instead of
|
||||
// the required "https://accounts.google.com". Detect this case and allow it only
|
||||
// for Google.
|
||||
//
|
||||
// We will not add hooks to let other providers go off spec like this.
|
||||
if !(v.issuer == issuerGoogleAccounts && t.Issuer == issuerGoogleAccountsNoScheme) {
|
||||
return nil, fmt.Errorf("oidc: id token issued by a different provider, expected %q got %q", v.issuer, t.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
if v.config.ClientID != "" {
|
||||
if !contains(t.Audience, v.config.ClientID) {
|
||||
return nil, fmt.Errorf("oidc: expected audience %q got %q", v.config.ClientID, t.Audience)
|
||||
}
|
||||
}
|
||||
|
||||
// If a SkipExpiryCheck is false, make sure token is not expired.
|
||||
if !v.config.SkipExpiryCheck {
|
||||
now := time.Now
|
||||
if v.config.Now != nil {
|
||||
now = v.config.Now
|
||||
}
|
||||
nowTime := now()
|
||||
|
||||
if t.Expiry.Before(nowTime) {
|
||||
return nil, &TokenExpiredError{Expiry: t.Expiry}
|
||||
}
|
||||
|
||||
// If nbf claim is provided in token, ensure that it is indeed in the past.
|
||||
if token.NotBefore != nil {
|
||||
nbfTime := time.Time(*token.NotBefore)
|
||||
// Set to 5 minutes since this is what other OpenID Connect providers do to deal with clock skew.
|
||||
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/6.12.2/src/Microsoft.IdentityModel.Tokens/TokenValidationParameters.cs#L149-L153
|
||||
|
||||
if nowTime.Add(LEEWAY).Before(nbfTime) {
|
||||
return nil, fmt.Errorf("oidc: current time %v before the nbf (not before) time: %v", nowTime, nbfTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jws, err := jose.ParseSigned(rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
|
||||
}
|
||||
|
||||
switch len(jws.Signatures) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("oidc: id token not signed")
|
||||
case 1:
|
||||
default:
|
||||
return nil, fmt.Errorf("oidc: multiple signatures on id token not supported")
|
||||
}
|
||||
|
||||
sig := jws.Signatures[0]
|
||||
supportedSigAlgs := v.config.SupportedSigningAlgs
|
||||
|
||||
if len(supportedSigAlgs) == 0 {
|
||||
supportedSigAlgs = []string{RS256}
|
||||
}
|
||||
|
||||
if !contains(supportedSigAlgs, sig.Header.Algorithm) {
|
||||
return nil, fmt.Errorf("oidc: id token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm)
|
||||
}
|
||||
|
||||
t.sigAlgorithm = sig.Header.Algorithm
|
||||
|
||||
keyID := ""
|
||||
for _, sig := range jws.Signatures {
|
||||
keyID = sig.Header.KeyID
|
||||
break
|
||||
}
|
||||
|
||||
for _, key := range keySet.Keys {
|
||||
if keyID == "" || key.KeyID == keyID {
|
||||
if gotPayload, err := jws.Verify(&key); err == nil {
|
||||
if !bytes.Equal(gotPayload, payload) {
|
||||
return nil, errors.New("oidc: internal error, payload parsed did not match previous payload")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
func contains(sli []string, ele string) bool {
|
||||
for _, s := range sli {
|
||||
if s == ele {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func parseJWT(p string) ([]byte, error) {
|
||||
parts := strings.Split(p, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
func (cfg *Oatuh2Config) Verifier(config *IDConfig) *IDTokenVerifier {
|
||||
return &IDTokenVerifier{
|
||||
config: config,
|
||||
issuer: cfg.Issuer,
|
||||
}
|
||||
}
|
||||