Browse Source

Merge branch 'dexidp:master' into oauth_add_email_verfied_override

pull/3811/head
Darius 1 year ago committed by GitHub
parent
commit
ca42371edc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 28
      .github/workflows/artifacts.yaml
  2. 6
      .github/workflows/ci.yaml
  3. 39
      .github/workflows/trivydb-cache.yaml
  4. 12
      Dockerfile
  5. 2
      README.md
  6. 18
      connector/oidc/oidc.go
  7. 49
      connector/oidc/oidc_test.go
  8. 371
      examples/example-app/templates.go
  9. 2
      examples/go.mod
  10. 4
      examples/go.sum
  11. 2
      go.mod
  12. 4
      go.sum
  13. 24
      server/api.go
  14. 89
      server/handlers.go
  15. 2
      server/handlers_test.go

28
.github/workflows/artifacts.yaml

@ -61,7 +61,7 @@ jobs:
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Set up Syft
uses: anchore/sbom-action/download-syft@1ca97d9028b51809cf6d3c934c3e160716e1b605 # v0.17.5
uses: anchore/sbom-action/download-syft@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8
- name: Install cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
@ -192,12 +192,38 @@ jobs:
push-to-registry: true
if: inputs.publish
## Use cache for the trivy-db to avoid the TOOMANYREQUESTS error https://github.com/aquasecurity/trivy-action/pull/397
## To avoid the trivy-db becoming outdated, we save the cache for one day
- name: Get data
id: date
run: echo "date=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT
- name: Restore trivy cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: cache/db
key: trivy-cache-${{ steps.date.outputs.date }}
restore-keys:
trivy-cache-
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2 # 0.28.0
with:
input: image
format: sarif
output: trivy-results.sarif
scan-type: 'fs'
scan-ref: '.'
cache-dir: "./cache"
env:
TRIVY_SKIP_DB_UPDATE: true
TRIVY_SKIP_JAVA_DB_UPDATE: true
## Trivy-db uses `0600` permissions.
## But `action/cache` use `runner` user by default
## So we need to change the permissions before caching the database.
- name: change permissions for trivy.db
run: sudo chmod 0644 ./cache/db/trivy.db
- name: Upload Trivy scan results as artifact
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0

6
.github/workflows/ci.yaml

@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: "1.21"
@ -140,7 +140,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: "1.21"
@ -175,4 +175,4 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Dependency Review
uses: actions/dependency-review-action@a6993e2c61fd5dc440b409aa1d6904921c5e1894 # v4.3.5
uses: actions/dependency-review-action@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0

39
.github/workflows/trivydb-cache.yaml

@ -0,0 +1,39 @@
# Note: This workflow only updates the cache. You should create a separate workflow for your actual Trivy scans.
# In your scan workflow, set TRIVY_SKIP_DB_UPDATE=true and TRIVY_SKIP_JAVA_DB_UPDATE=true.
name: Update Trivy Cache
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
workflow_dispatch: # Allow manual triggering
jobs:
update-trivy-db:
runs-on: ubuntu-latest
steps:
- name: Setup oras
uses: oras-project/setup-oras@9c92598691bfef1424de2f8fae81941568f5889c # v1.2.1
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Download and extract the vulnerability DB
run: |
mkdir -p $GITHUB_WORKSPACE/.cache/trivy/db
oras pull ghcr.io/aquasecurity/trivy-db:2
tar -xzf db.tar.gz -C $GITHUB_WORKSPACE/.cache/trivy/db
rm db.tar.gz
- name: Download and extract the Java DB
run: |
mkdir -p $GITHUB_WORKSPACE/.cache/trivy/java-db
oras pull ghcr.io/aquasecurity/trivy-java-db:1
tar -xzf javadb.tar.gz -C $GITHUB_WORKSPACE/.cache/trivy/java-db
rm javadb.tar.gz
- name: Cache DBs
uses: actions/cache/save@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
with:
path: ${{ github.workspace }}/.cache/trivy
key: cache-trivy-${{ steps.date.outputs.date }}

12
Dockerfile

@ -1,8 +1,8 @@
ARG BASE_IMAGE=alpine
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.5.0@sha256:0c6a569797744e45955f39d4f7538ac344bfb7ebf0a54006a0a4297b153ccf0f AS xx
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.6.1@sha256:923441d7c25f1e2eb5789f82d987693c47b8ed987c4ab3b075d6ed2b5d6779a3 AS xx
FROM --platform=$BUILDPLATFORM golang:1.23.2-alpine3.20@sha256:9dd2625a1ff2859b8d8b01d8f7822c0f528942fe56cfe7a1e7c38d3b8d72d679 AS builder
FROM --platform=$BUILDPLATFORM golang:1.23.3-alpine3.20@sha256:c694a4d291a13a9f9d94933395673494fc2cc9d4777b85df3a7e70b3492d3574 AS builder
COPY --from=xx / /
@ -35,13 +35,13 @@ RUN make release-binary
RUN xx-verify /go/bin/dex && xx-verify /go/bin/docker-entrypoint
FROM alpine:3.20.3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS stager
FROM alpine:3.20.3@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a AS stager
RUN mkdir -p /var/dex
RUN mkdir -p /etc/dex
COPY config.docker.yaml /etc/dex/
FROM alpine:3.20.3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS gomplate
FROM alpine:3.20.3@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a AS gomplate
ARG TARGETOS
ARG TARGETARCH
@ -54,8 +54,8 @@ RUN wget -O /usr/local/bin/gomplate \
&& chmod +x /usr/local/bin/gomplate
# For Dependabot to detect base image versions
FROM alpine:3.20.3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS alpine
FROM gcr.io/distroless/static-debian12:nonroot@sha256:26f9b99f2463f55f20db19feb4d96eb88b056e0f1be7016bb9296a464a89d772 AS distroless
FROM alpine:3.20.3@sha256:1e42bbe2508154c9126d48c2b8a75420c3544343bf86fd041fb7527e017a4b4a AS alpine
FROM gcr.io/distroless/static-debian12:nonroot@sha256:d71f4b239be2d412017b798a0a401c44c3049a3ca454838473a4c32ed076bfea AS distroless
FROM $BASE_IMAGE

2
README.md

@ -79,7 +79,7 @@ Dex implements the following connectors:
| [AuthProxy](https://dexidp.io/docs/connectors/authproxy/) | no | yes | no | alpha | Authentication proxies such as Apache2 mod_auth, etc. |
| [Bitbucket Cloud](https://dexidp.io/docs/connectors/bitbucketcloud/) | yes | yes | no | alpha | |
| [OpenShift](https://dexidp.io/docs/connectors/openshift/) | yes | yes | no | alpha | |
| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config |
| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassian-crowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config |
| [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | |
| [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | |

18
connector/oidc/oidc.go

@ -23,7 +23,12 @@ import (
// Config holds configuration options for OpenID Connect logins.
type Config struct {
Issuer string `json:"issuer"`
Issuer string `json:"issuer"`
// Some offspec providers like Azure, Oracle IDCS have oidc discovery url
// different from issuer url which causes issuerValidation to fail
// IssuerAlias provides a way to override the Issuer url
// from the .well-known/openid-configuration issuer
IssuerAlias string `json:"issuerAlias"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectURI"`
@ -226,7 +231,9 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector,
bgctx, cancel := context.WithCancel(context.Background())
ctx := context.WithValue(bgctx, oauth2.HTTPClient, httpClient)
if c.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, c.IssuerAlias)
}
provider, err := getProvider(ctx, c.Issuer, c.ProviderDiscoveryOverrides)
if err != nil {
cancel()
@ -540,6 +547,13 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I
continue
}
groups = append(groups, s)
} else if groupMap, ok := v.(map[string]interface{}); ok {
if s, ok := groupMap["name"].(string); ok {
if c.groupsFilter != nil && !c.groupsFilter.MatchString(s) {
continue
}
groups = append(groups, s)
}
} else {
return identity, fmt.Errorf("malformed \"%v\" claim", groupsKey)
}

49
connector/oidc/oidc_test.go

@ -292,6 +292,38 @@ func TestHandleCallback(t *testing.T) {
"email_verified": true,
},
},
{
name: "singularGroupResponseAsMap",
userIDKey: "", // not configured
userNameKey: "", // not configured
expectUserID: "subvalue",
expectUserName: "namevalue",
expectGroups: []string{"group1"},
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"groups": []map[string]string{{"name": "group1"}},
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "multipleGroupResponseAsMap",
userIDKey: "", // not configured
userNameKey: "", // not configured
expectUserID: "subvalue",
expectUserName: "namevalue",
expectGroups: []string{"group1", "group2"},
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"groups": []map[string]string{{"name": "group1"}, {"name": "group2"}},
"email": "emailvalue",
"email_verified": true,
},
},
{
name: "newGroupFromClaims",
userIDKey: "", // not configured
@ -382,6 +414,23 @@ func TestHandleCallback(t *testing.T) {
"email_verified": true,
},
},
{
name: "filterGroupClaimsMap",
userIDKey: "", // not configured
userNameKey: "", // not configured
groupsRegex: `^.*\d$`,
expectUserID: "subvalue",
expectUserName: "namevalue",
expectGroups: []string{"group1", "group2"},
expectedEmailField: "emailvalue",
token: map[string]interface{}{
"sub": "subvalue",
"name": "namevalue",
"groups": []map[string]string{{"name": "group1"}, {"name": "group2"}, {"name": "groupA"}, {"name": "groupB"}},
"email": "emailvalue",
"email_verified": true,
},
},
}
for _, tc := range tests {

371
examples/example-app/templates.go

@ -6,38 +6,225 @@ import (
"net/http"
)
const css = `
body {
font-family: Arial, sans-serif;
background-color: #f2f2f2;
margin: 0;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.dex {
font-size: 2em;
font-weight: bold;
color: #3F9FD8; /* Main color */
}
.example-app {
font-size: 1em;
color: #EF4B5C; /* Secondary color */
}
.form-instructions {
text-align: center;
margin-bottom: 15px;
font-size: 1em;
color: #555;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin-top: 10px;
margin-bottom: 20px;
}
label {
flex: 1;
font-weight: bold;
color: #333;
}
p {
margin-bottom: 15px;
display: flex;
align-items: center;
}
input[type="text"] {
flex: 2;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
outline: none;
}
input[type="checkbox"] {
margin-left: 10px;
transform: scale(1.2);
}
.back-button {
display: inline-block;
padding: 8px 16px;
background-color: #EF4B5C; /* Secondary color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
text-decoration: none;
transition: background-color 0.3s ease, transform 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: fixed;
right: 20px;
bottom: 20px;
}
.back-button:hover {
background-color: #C43B4B; /* Darker shade of secondary color */
}
.token-block {
background-color: #fff;
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
word-wrap: break-word;
display: flex;
flex-direction: column;
gap: 5px;
position: relative;
}
.token-title {
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
}
.token-title a {
font-size: 0.9em;
text-decoration: none;
color: #3F9FD8; /* Main color */
}
.token-title a:hover {
text-decoration: underline;
}
.token-code {
overflow-wrap: break-word;
word-break: break-all;
white-space: normal;
}
pre {
white-space: pre-wrap;
background-color: #f9f9f9;
padding: 8px;
border-radius: 4px;
border: 1px solid #ddd;
margin: 0;
font-family: 'Courier New', Courier, monospace;
overflow-x: auto;
font-size: 0.9em;
position: relative;
margin-top: 5px;
}
pre .key {
color: #c00;
}
pre .string {
color: #080;
}
pre .number {
color: #00f;
}
`
var indexTmpl = template.Must(template.New("index.html").Parse(`<html>
<head>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example App - Login</title>
<style>
form { display: table; }
p { display: table-row; }
label { display: table-cell; }
input { display: table-cell; }
` + css + `
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
}
form {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
input[type="submit"] {
width: 100%;
padding: 10px;
background-color: #3F9FD8; /* Main color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
input[type="submit"]:hover {
background-color: #357FAA; /* Darker shade of main color */
}
</style>
</head>
<body>
</head>
<body>
<div class="header">
<div class="dex">Dex</div>
<div class="example-app">Example App</div>
</div>
<form action="/login" method="post">
<p>
<label> Authenticate for: </label>
<input type="text" name="cross_client" placeholder="list of client-ids">
</p>
<p>
<label>Extra scopes: </label>
<input type="text" name="extra_scopes" placeholder="list of scopes">
</p>
<p>
<label>Connector ID: </label>
<input type="text" name="connector_id" placeholder="connector id">
</p>
<p>
<label>Request offline access: </label>
<input type="checkbox" name="offline_access" value="yes" checked>
</p>
<p>
<input type="submit" value="Login">
</p>
<div class="form-instructions">
If needed, customize your login settings below, then click <strong>Login</strong> to proceed.
</div>
<hr/>
<p>
<label for="cross_client">Authenticate for:</label>
<input type="text" id="cross_client" name="cross_client" placeholder="list of client-ids">
</p>
<p>
<label for="extra_scopes">Extra scopes:</label>
<input type="text" id="extra_scopes" name="extra_scopes" placeholder="list of scopes">
</p>
<p>
<label for="connector_id">Connector ID:</label>
<input type="text" id="connector_id" name="connector_id" placeholder="connector id">
</p>
<p>
<label for="offline_access">Request offline access:</label>
<input type="checkbox" id="offline_access" name="offline_access" value="yes" checked>
</p>
<p>
<input type="submit" value="Login">
</p>
</form>
</body>
</body>
</html>`))
func renderIndex(w http.ResponseWriter) {
@ -53,30 +240,116 @@ type tokenTmplData struct {
}
var tokenTmpl = template.Must(template.New("token.html").Parse(`<html>
<head>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tokens</title>
<style>
/* make pre wrap */
pre {
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
` + css + `
body {
color: #333;
margin: 0;
padding: 20px;
position: relative;
}
input[type="submit"] {
margin-top: 10px;
padding: 8px 16px;
background-color: #3F9FD8; /* Main color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
input[type="submit"]:hover {
background-color: #357FAA; /* Darker shade of main color */
}
</style>
</head>
<body>
<p> ID Token: <pre><code>{{ .IDToken }}</code></pre></p>
<p> Access Token: <pre><code>{{ .AccessToken }}</code></pre></p>
<p> Claims: <pre><code>{{ .Claims }}</code></pre></p>
{{ if .RefreshToken }}
<p> Refresh Token: <pre><code>{{ .RefreshToken }}</code></pre></p>
<form action="{{ .RedirectURL }}" method="post">
<input type="hidden" name="refresh_token" value="{{ .RefreshToken }}">
<input type="submit" value="Redeem refresh token">
</form>
{{ end }}
</body>
</head>
<body>
{{ if .IDToken }}
<div class="token-block">
<div class="token-title">
ID Token:
<a href="#" onclick="window.open('https://jwt.io/#debugger-io?token=' + encodeURIComponent('{{ .IDToken }}'), '_blank')">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .IDToken }}</code></pre>
</div>
{{ end }}
{{ if .AccessToken }}
<div class="token-block">
<div class="token-title">
Access Token:
<a href="#" onclick="window.open('https://jwt.io/#debugger-io?token=' + encodeURIComponent('{{ .AccessToken }}'), '_blank')">Decode on jwt.io</a>
</div>
<pre><code class="token-code">{{ .AccessToken }}</code></pre>
</div>
{{ end }}
{{ if .Claims }}
<div class="token-block">
<div class="token-title">Claims:</div>
<pre><code id="claims">{{ .Claims }}</code></pre>
</div>
{{ end }}
{{ if .RefreshToken }}
<div class="token-block">
<div class="token-title">Refresh Token:</div>
<pre><code class="token-code">{{ .RefreshToken }}</code></pre>
<form action="{{ .RedirectURL }}" method="post">
<input type="hidden" name="refresh_token" value="{{ .RefreshToken }}">
<input type="submit" value="Redeem refresh token">
</form>
</div>
{{ end }}
<a href="/" class="back-button">Back to Home</a>
<script>
// Simple JSON syntax highlighter
document.addEventListener("DOMContentLoaded", function() {
const claimsElement = document.getElementById("claims");
if (claimsElement) {
try {
const json = JSON.parse(claimsElement.textContent);
claimsElement.innerHTML = syntaxHighlight(json);
} catch (e) {
console.error("Invalid JSON in claims:", e);
}
}
});
function syntaxHighlight(json) {
if (typeof json != 'string') {
json = JSON.stringify(json, undefined, 2);
}
json = json.replace(/&/g, '&amp;').replace(/</g, '<').replace(/>/g, '>');
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|\b\d+\b)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
</script>
</body>
</html>
`))

2
examples/go.mod

@ -6,7 +6,7 @@ require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/dexidp/dex/api/v2 v2.2.0
github.com/spf13/cobra v1.8.1
golang.org/x/oauth2 v0.23.0
golang.org/x/oauth2 v0.24.0
google.golang.org/grpc v1.67.1
)

4
examples/go.sum

@ -24,8 +24,8 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=

2
go.mod

@ -11,7 +11,7 @@ require (
github.com/beevik/etree v1.4.0
github.com/coreos/go-oidc/v3 v3.11.0
github.com/dexidp/dex/api/v2 v2.1.0
github.com/fsnotify/fsnotify v1.7.0
github.com/fsnotify/fsnotify v1.8.0
github.com/ghodss/yaml v1.0.0
github.com/go-jose/go-jose/v4 v4.0.4
github.com/go-ldap/ldap/v3 v3.4.8

4
go.sum

@ -65,8 +65,8 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=

24
server/api.go

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"strconv"
"golang.org/x/crypto/bcrypt"
@ -430,10 +431,11 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq
}
c := storage.Connector{
ID: req.Connector.Id,
Name: req.Connector.Name,
Type: req.Connector.Type,
Config: req.Connector.Config,
ID: req.Connector.Id,
Name: req.Connector.Name,
Type: req.Connector.Type,
ResourceVersion: "1",
Config: req.Connector.Config,
}
if err := d.s.CreateConnector(ctx, c); err != nil {
if err == storage.ErrAlreadyExists {
@ -446,7 +448,7 @@ func (d dexAPI) CreateConnector(ctx context.Context, req *api.CreateConnectorReq
return &api.CreateConnectorResp{}, nil
}
func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq) (*api.UpdateConnectorResp, error) {
func (d dexAPI) UpdateConnector(_ context.Context, req *api.UpdateConnectorReq) (*api.UpdateConnectorResp, error) {
if !featureflags.APIConnectorsCRUD.Enabled() {
return nil, fmt.Errorf("%s feature flag is not enabled", featureflags.APIConnectorsCRUD.Name)
}
@ -476,6 +478,10 @@ func (d dexAPI) UpdateConnector(ctx context.Context, req *api.UpdateConnectorReq
old.Config = req.NewConfig
}
if rev, err := strconv.Atoi(defaultTo(old.ResourceVersion, "0")); err == nil {
old.ResourceVersion = strconv.Itoa(rev + 1)
}
return old, nil
}
@ -536,3 +542,11 @@ func (d dexAPI) ListConnectors(ctx context.Context, req *api.ListConnectorReq) (
Connectors: connectors,
}, nil
}
func defaultTo[T comparable](v, def T) T {
var zeroT T
if v == zeroT {
return def
}
return v
}

89
server/handlers.go

@ -534,23 +534,6 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity,
"connector_id", authReq.ConnectorID, "username", claims.Username,
"preferred_username", claims.PreferredUsername, "email", email, "groups", claims.Groups)
// we can skip the redirect to /approval and go ahead and send code if it's not required
if s.skipApproval && !authReq.ForceApprovalPrompt {
return "", true, nil
}
// an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original
// flow would be unable to poll for the result at the /approval endpoint
h := hmac.New(sha256.New, authReq.HMACKey)
h.Write([]byte(authReq.ID))
mac := h.Sum(nil)
returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + base64.RawURLEncoding.EncodeToString(mac)
_, ok := conn.(connector.RefreshConnector)
if !ok {
return returnURL, false, nil
}
offlineAccessRequested := false
for _, scope := range authReq.Scopes {
if scope == scopeOfflineAccess {
@ -558,45 +541,55 @@ func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity,
break
}
}
if !offlineAccessRequested {
return returnURL, false, nil
}
_, canRefresh := conn.(connector.RefreshConnector)
// Try to retrieve an existing OfflineSession object for the corresponding user.
session, err := s.storage.GetOfflineSessions(identity.UserID, authReq.ConnectorID)
if err != nil {
if err != storage.ErrNotFound {
s.logger.ErrorContext(ctx, "failed to get offline session", "err", err)
return "", false, err
}
offlineSessions := storage.OfflineSessions{
UserID: identity.UserID,
ConnID: authReq.ConnectorID,
Refresh: make(map[string]*storage.RefreshTokenRef),
ConnectorData: identity.ConnectorData,
}
if offlineAccessRequested && canRefresh {
// Try to retrieve an existing OfflineSession object for the corresponding user.
session, err := s.storage.GetOfflineSessions(identity.UserID, authReq.ConnectorID)
switch {
case err != nil && err == storage.ErrNotFound:
offlineSessions := storage.OfflineSessions{
UserID: identity.UserID,
ConnID: authReq.ConnectorID,
Refresh: make(map[string]*storage.RefreshTokenRef),
ConnectorData: identity.ConnectorData,
}
// Create a new OfflineSession object for the user and add a reference object for
// the newly received refreshtoken.
if err := s.storage.CreateOfflineSessions(ctx, offlineSessions); err != nil {
s.logger.ErrorContext(ctx, "failed to create offline session", "err", err)
// Create a new OfflineSession object for the user and add a reference object for
// the newly received refreshtoken.
if err := s.storage.CreateOfflineSessions(ctx, offlineSessions); err != nil {
s.logger.ErrorContext(ctx, "failed to create offline session", "err", err)
return "", false, err
}
case err == nil:
// Update existing OfflineSession obj with new RefreshTokenRef.
if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
if len(identity.ConnectorData) > 0 {
old.ConnectorData = identity.ConnectorData
}
return old, nil
}); err != nil {
s.logger.ErrorContext(ctx, "failed to update offline session", "err", err)
return "", false, err
}
default:
s.logger.ErrorContext(ctx, "failed to get offline session", "err", err)
return "", false, err
}
return returnURL, false, nil
}
// Update existing OfflineSession obj with new RefreshTokenRef.
if err := s.storage.UpdateOfflineSessions(session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
if len(identity.ConnectorData) > 0 {
old.ConnectorData = identity.ConnectorData
}
return old, nil
}); err != nil {
s.logger.ErrorContext(ctx, "failed to update offline session", "err", err)
return "", false, err
// we can skip the redirect to /approval and go ahead and send code if it's not required
if s.skipApproval && !authReq.ForceApprovalPrompt {
return "", true, nil
}
// an HMAC is used here to ensure that the request ID is unpredictable, ensuring that an attacker who intercepted the original
// flow would be unable to poll for the result at the /approval endpoint
h := hmac.New(sha256.New, authReq.HMACKey)
h.Write([]byte(authReq.ID))
mac := h.Sum(nil)
returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + base64.RawURLEncoding.EncodeToString(mac)
return returnURL, false, nil
}

2
server/handlers_test.go

@ -519,7 +519,7 @@ func TestHandlePasswordLoginWithSkipApproval(t *testing.T) {
Scopes: []string{"offline_access"},
},
expectedRes: "/auth/mockPw/cb",
offlineSessionCreated: false,
offlineSessionCreated: true,
},
}

Loading…
Cancel
Save