Skip to content

Commit 4204f0b

Browse files
committed
oidc: add support for validating back-channel logout tokens
This PR adds support for OpenID Connect Back-Channel Logout, where a relying party can receive a POST request from an identity provider indicating a user's sessions should be revoked. Because so much of this logic replicates the ID Token claims checks, the API has been added to the existing IDTokenVerifier with a new VerifyLogout method to go with the existing Verify method. A full example app that has been tested against Auth0's Back-Channel logout support is added to examples. https://openid.net/specs/openid-connect-backchannel-1_0.html
1 parent f77e01c commit 4204f0b

5 files changed

Lines changed: 1073 additions & 45 deletions

File tree

example/logout/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Logout example app
2+
3+
This logout example app demonstrates use of the logout endpoint. As opposed to
4+
the other example apps, it runs against Auth0 rather than Google. This works
5+
best with:
6+
7+
- An Auth0 [developer instance][auth0-dev].
8+
- A [Tailscale funnel][tailscale-funnel] or [Cloudflare Tunnel][cloudflare-tunnel]
9+
to advertise a local logout endpoint on the internet.
10+
11+
[auth0-dev]: https://developer.auth0.com/
12+
[cloudflare-tunnel]: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
13+
[tailscale-funnel]: https://tailscale.com/docs/features/tailscale-funnel
14+
15+
Using Tailscale, the tunnel will advertise on a DNS name like:
16+
17+
```
18+
BASE_URL="https://${MY_HOST}.ts.net"
19+
```
20+
21+
Within the Auth0 application, configure "Allowed Callback URLs" with the
22+
following value:
23+
24+
```
25+
${BASE_URL}/callback
26+
```
27+
28+
And "Back-Channel Logout URI" with:
29+
30+
```
31+
${BASE_URL}/logout
32+
```
33+
34+
Then run the logout app with:
35+
36+
```
37+
export CLIENT_ID="{AUTH0_CLIENT_ID}"
38+
export CLIENT_SECRET="{AUTH0_CLIENT_SECRET}"
39+
go run ./example/logout/app.go \
40+
--base-url="${BASE_URL}" \
41+
--issuer-url="${AUTH0_ISSUER_URL}"
42+
```
43+
44+
After logging into the app, there will be a "Logout" link at the bottom of the
45+
page that performs RP-Initiated Logout. Clicking on that link, you will logout
46+
through Auth0, and trigger a POST to the logout endpoint.
47+
48+
Once the app receives the logout token, it will validate it and log a message:
49+
50+
```
51+
2026/06/17 13:06:39 Logout token: {
52+
"Issuer": "${AUTH0_ISSUER_URL}",
53+
"Subject": "1234",
54+
"Audience": [
55+
"${CLIENT_ID}"
56+
],
57+
"IssuedAt": "2026-06-17T13:06:39-07:00",
58+
"Expiry": "2026-06-17T13:08:39-07:00",
59+
"SessionID": "Kaeo_qJ9zFDcWI9g_fNVa24rv7uu1gpV"
60+
}
61+
```

example/logout/app.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
This is an example application to demonstrate parsing an ID Token.
3+
*/
4+
package main
5+
6+
import (
7+
"context"
8+
"crypto/rand"
9+
"encoding/base64"
10+
"encoding/json"
11+
"flag"
12+
"fmt"
13+
"html/template"
14+
"io"
15+
"log"
16+
"net/http"
17+
"net/url"
18+
"os"
19+
"strconv"
20+
"time"
21+
22+
"github.com/coreos/go-oidc/v3/oidc"
23+
"golang.org/x/oauth2"
24+
)
25+
26+
var (
27+
clientID = os.Getenv("CLIENT_ID")
28+
clientSecret = os.Getenv("CLIENT_SECRET")
29+
)
30+
31+
func randString(nByte int) (string, error) {
32+
b := make([]byte, nByte)
33+
if _, err := io.ReadFull(rand.Reader, b); err != nil {
34+
return "", err
35+
}
36+
return base64.RawURLEncoding.EncodeToString(b), nil
37+
}
38+
39+
func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
40+
c := &http.Cookie{
41+
Name: name,
42+
Value: value,
43+
MaxAge: int(time.Hour.Seconds()),
44+
Secure: r.TLS != nil,
45+
HttpOnly: true,
46+
}
47+
http.SetCookie(w, c)
48+
}
49+
50+
func main() {
51+
var (
52+
baseURL string
53+
issuerURL string
54+
port uint64
55+
)
56+
flag.StringVar(&baseURL, "base-url", "",
57+
"Base URL that the server is running on such as 'https://example.com'")
58+
flag.Uint64Var(&port, "port", 3000,
59+
"Port to serve locally.")
60+
flag.StringVar(&issuerURL, "issuer-url", "",
61+
"OpenID Connect Issuer URL to listen for")
62+
flag.Parse()
63+
if baseURL == "" {
64+
log.Fatalf("No --base-url provided")
65+
}
66+
if issuerURL == "" {
67+
log.Fatalf("No --issuer-url provided")
68+
}
69+
if clientID == "" {
70+
log.Fatalf("No CLIENT_ID environment variable set")
71+
}
72+
if clientSecret == "" {
73+
log.Fatalf("No CLIENT_SECRET environment variable set")
74+
}
75+
76+
ctx := context.Background()
77+
78+
callbackURL, err := url.JoinPath(baseURL, "/callback")
79+
if err != nil {
80+
log.Fatalf("Generating callback URL: %v", err)
81+
}
82+
83+
provider, err := oidc.NewProvider(ctx, issuerURL)
84+
if err != nil {
85+
log.Fatal(err)
86+
}
87+
var metadata struct {
88+
EndSessionEndpoint string `json:"end_session_endpoint"`
89+
}
90+
if err := provider.Claims(&metadata); err != nil {
91+
log.Fatalf("Parsing metadata: %v", err)
92+
}
93+
if metadata.EndSessionEndpoint == "" {
94+
log.Fatalf("Provider doesn't have end_session_endpoint")
95+
}
96+
97+
oidcConfig := &oidc.Config{
98+
ClientID: clientID,
99+
}
100+
verifier := provider.Verifier(oidcConfig)
101+
102+
config := oauth2.Config{
103+
ClientID: clientID,
104+
ClientSecret: clientSecret,
105+
Endpoint: provider.Endpoint(),
106+
RedirectURL: callbackURL,
107+
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
108+
}
109+
110+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
111+
state, err := randString(16)
112+
if err != nil {
113+
http.Error(w, "Internal error", http.StatusInternalServerError)
114+
return
115+
}
116+
nonce, err := randString(16)
117+
if err != nil {
118+
http.Error(w, "Internal error", http.StatusInternalServerError)
119+
return
120+
}
121+
setCallbackCookie(w, r, "state", state)
122+
setCallbackCookie(w, r, "nonce", nonce)
123+
124+
http.Redirect(w, r, config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
125+
})
126+
127+
http.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) {
128+
token := r.PostFormValue("logout_token")
129+
if token == "" {
130+
log.Println("No logout_token")
131+
http.Error(w, "No logout token", http.StatusBadRequest)
132+
return
133+
}
134+
logoutToken, err := verifier.VerifyLogout(r.Context(), token)
135+
if err != nil {
136+
log.Printf("Failed to validate logout token: %v", err)
137+
http.Error(w, "Invalid logout token", http.StatusBadRequest)
138+
return
139+
}
140+
data, _ := json.MarshalIndent(logoutToken, "", " ")
141+
log.Printf("Logout token: %s", data)
142+
w.WriteHeader(http.StatusNoContent)
143+
})
144+
145+
http.HandleFunc("GET /callback", func(w http.ResponseWriter, r *http.Request) {
146+
state, err := r.Cookie("state")
147+
if err != nil {
148+
http.Error(w, "state not found", http.StatusBadRequest)
149+
return
150+
}
151+
if r.URL.Query().Get("state") != state.Value {
152+
http.Error(w, "state did not match", http.StatusBadRequest)
153+
return
154+
}
155+
156+
oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"))
157+
if err != nil {
158+
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
159+
return
160+
}
161+
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
162+
if !ok {
163+
http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError)
164+
return
165+
}
166+
idToken, err := verifier.Verify(ctx, rawIDToken)
167+
if err != nil {
168+
http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
169+
return
170+
}
171+
172+
nonce, err := r.Cookie("nonce")
173+
if err != nil {
174+
http.Error(w, "nonce not found", http.StatusBadRequest)
175+
return
176+
}
177+
if idToken.Nonce != nonce.Value {
178+
http.Error(w, "nonce did not match", http.StatusBadRequest)
179+
return
180+
}
181+
182+
oauth2Token.AccessToken = "*REDACTED*"
183+
184+
resp := struct {
185+
OAuth2Token *oauth2.Token
186+
IDTokenClaims *json.RawMessage // ID Token payload is just JSON.
187+
}{oauth2Token, new(json.RawMessage)}
188+
189+
if err := idToken.Claims(&resp.IDTokenClaims); err != nil {
190+
http.Error(w, err.Error(), http.StatusInternalServerError)
191+
return
192+
}
193+
data, err := json.MarshalIndent(resp, "", " ")
194+
if err != nil {
195+
http.Error(w, err.Error(), http.StatusInternalServerError)
196+
return
197+
}
198+
endSessionEndpoint, err := url.Parse(metadata.EndSessionEndpoint)
199+
if err != nil {
200+
http.Error(w, fmt.Sprintf("Failed to parse end session endpoint :%v", err), http.StatusInternalServerError)
201+
return
202+
}
203+
v := endSessionEndpoint.Query()
204+
v.Add("id_token_hint", rawIDToken)
205+
endSessionEndpoint.RawQuery = v.Encode()
206+
d := tmplData{
207+
Payload: string(data),
208+
LoginURL: baseURL,
209+
LogoutURL: endSessionEndpoint.String(),
210+
}
211+
tmpl.Execute(w, d)
212+
})
213+
214+
log.Printf("listening on http://[::]:%d", port)
215+
log.Fatal(http.ListenAndServe(":"+strconv.FormatUint(port, 10), nil))
216+
}
217+
218+
type tmplData struct {
219+
Payload string
220+
LoginURL string
221+
LogoutURL string
222+
}
223+
224+
var tmpl = template.Must(template.New("").Parse(`<!DOCTYPE html>
225+
<html lang="en">
226+
<head>
227+
<meta charset="UTF-8">
228+
<title>go-oidc</title>
229+
</head>
230+
<body>
231+
<pre>
232+
<code>{{.Payload}}</code>
233+
</pre>
234+
<a href="{{.LoginURL}}">Reauth</a>
235+
<a href="{{.LogoutURL}}">Logout</a>
236+
</body>
237+
</html>`))

0 commit comments

Comments
 (0)