diff --git a/plugins/wasm-go/extensions/oidc/README.md b/plugins/wasm-go/extensions/oidc/README.md new file mode 100644 index 000000000..0eccaa866 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/README.md @@ -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`的标头携带令牌 + + + + + diff --git a/plugins/wasm-go/extensions/oidc/VERSION b/plugins/wasm-go/extensions/oidc/VERSION new file mode 100644 index 000000000..afaf360d3 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/oidc/doc/Oidc.md b/plugins/wasm-go/extensions/oidc/doc/Oidc.md new file mode 100644 index 000000000..0d9c399d5 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/doc/Oidc.md @@ -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_1.png](okta_1.png) +* 查看服务列表,有即成功 +![okta_2.png](okta_2.png) +### 将测试 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 +``` +### 访问服务页面,未登陆的话进行跳转 +![okta_3.png](okta_3.png) +### 登陆成功跳转到服务页面 +![okta_4.png](okta_4.png) + + + + +--- + +## OpenID Connect with auth0 +### 配置 auth0 账户 +* 登录到开发人员 Okta 网站 [Developer Auth0 site](https://auth0.com/) +* 注册测试 web 应用程序 + +### 将测试 auth0 应用程序与 Higress 关联 +* 创建服务来源 +![auth0_0.png](auth0_0.png) + +### 将测试 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 +``` + + + +### 访问服务页面,未登陆的话进行跳转 +![auth0_1.png](auth0_1.png) + +### 登陆成功跳转到服务页面 +![oath_2.png](oath_2.png) + +--- + + + +## OpenID Connect with keyclocak +### 配置 keyclocak 账户 +* 本文档采用 docker 本机进行部署,所以注册的 ip 应该采用 ifconfig 获取网卡 ip +![keycloak_0.png](keycloak_0.png) + +* 注册测试 web 应用程序 + +### 将测试 keyclocak 应用程序与 Higress 关联 +* 创建服务来源 + +![keycloak_1.png](keycloak_1.png) +### 将测试 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" +``` + + +### 访问服务页面,未登陆的话进行跳转 +![keycloak_2.png](keycloak_2.png) +### 登陆成功跳转到服务页面 +![keycloak_3.png](keycloak_3.png) + + +## 与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 | \ No newline at end of file diff --git a/plugins/wasm-go/extensions/oidc/doc/auth0_0.png b/plugins/wasm-go/extensions/oidc/doc/auth0_0.png new file mode 100644 index 000000000..1af35a98b Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/auth0_0.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/auth0_1.png b/plugins/wasm-go/extensions/oidc/doc/auth0_1.png new file mode 100644 index 000000000..a5048c270 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/auth0_1.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/keycloak_0.png b/plugins/wasm-go/extensions/oidc/doc/keycloak_0.png new file mode 100644 index 000000000..bbe11e362 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/keycloak_0.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/keycloak_1.png b/plugins/wasm-go/extensions/oidc/doc/keycloak_1.png new file mode 100644 index 000000000..182869b06 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/keycloak_1.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/keycloak_2.png b/plugins/wasm-go/extensions/oidc/doc/keycloak_2.png new file mode 100644 index 000000000..4e68d7c41 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/keycloak_2.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/keycloak_3.png b/plugins/wasm-go/extensions/oidc/doc/keycloak_3.png new file mode 100644 index 000000000..8618bbff5 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/keycloak_3.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/oath_2.png b/plugins/wasm-go/extensions/oidc/doc/oath_2.png new file mode 100644 index 000000000..ee20e9fb4 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/oath_2.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/okta_1.png b/plugins/wasm-go/extensions/oidc/doc/okta_1.png new file mode 100644 index 000000000..dce85babc Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/okta_1.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/okta_2.png b/plugins/wasm-go/extensions/oidc/doc/okta_2.png new file mode 100644 index 000000000..2adcb80f3 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/okta_2.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/okta_3.png b/plugins/wasm-go/extensions/oidc/doc/okta_3.png new file mode 100644 index 000000000..2141f1879 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/okta_3.png differ diff --git a/plugins/wasm-go/extensions/oidc/doc/okta_4.png b/plugins/wasm-go/extensions/oidc/doc/okta_4.png new file mode 100644 index 000000000..d21abe276 Binary files /dev/null and b/plugins/wasm-go/extensions/oidc/doc/okta_4.png differ diff --git a/plugins/wasm-go/extensions/oidc/go.mod b/plugins/wasm-go/extensions/oidc/go.mod new file mode 100644 index 000000000..8769a3919 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/go.mod @@ -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 +) diff --git a/plugins/wasm-go/extensions/oidc/go.sum b/plugins/wasm-go/extensions/oidc/go.sum new file mode 100644 index 000000000..41b2c4df3 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/go.sum @@ -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= diff --git a/plugins/wasm-go/extensions/oidc/main.go b/plugins/wasm-go/extensions/oidc/main.go new file mode 100644 index 000000000..705f40dad --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/main.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/oidc/oc/config.go b/plugins/wasm-go/extensions/oidc/oc/config.go new file mode 100644 index 000000000..b5c228ccf --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/config.go @@ -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://") + } +} diff --git a/plugins/wasm-go/extensions/oidc/oc/cookie.go b/plugins/wasm-go/extensions/oidc/oc/cookie.go new file mode 100644 index 000000000..834bee32f --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/cookie.go @@ -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()) +} diff --git a/plugins/wasm-go/extensions/oidc/oc/encryption.go b/plugins/wasm-go/extensions/oidc/oc/encryption.go new file mode 100644 index 000000000..9ffa77a97 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/encryption.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/oidc/oc/exchange.go b/plugins/wasm-go/extensions/oidc/oc/exchange.go new file mode 100644 index 000000000..c46d70d4a --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/exchange.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/oidc/oc/jwks.go b/plugins/wasm-go/extensions/oidc/oc/jwks.go new file mode 100644 index 000000000..a44a7fd64 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/jwks.go @@ -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) + } + +} diff --git a/plugins/wasm-go/extensions/oidc/oc/provider.go b/plugins/wasm-go/extensions/oidc/oc/provider.go new file mode 100644 index 000000000..506bc8fae --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/provider.go @@ -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) +} diff --git a/plugins/wasm-go/extensions/oidc/oc/util.go b/plugins/wasm-go/extensions/oidc/oc/util.go new file mode 100644 index 000000000..ed8695b87 --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/util.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/oidc/oc/verifer.go b/plugins/wasm-go/extensions/oidc/oc/verifer.go new file mode 100644 index 000000000..033712dec --- /dev/null +++ b/plugins/wasm-go/extensions/oidc/oc/verifer.go @@ -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, + } +}