80-column limit. I print code sometimes.
[fbfp.git] / oidc.go
blob78cd380d6ba863cbe4fd95ab45d5cddbf557679e
1 package main
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log"
9 "net/http"
11 "github.com/MicahParks/keyfunc/v3"
12 "github.com/golang-jwt/jwt/v5"
15 var openid_configuration struct {
16 AuthorizationEndpoint string `json:"authorization_endpoint"`
17 TokenEndpoint string `json:"token_endpoint"`
18 JwksUri string `json:"jwks_uri"`
19 UserinfoEndpoint string `json:"userinfo_endpoint"`
22 var openid_keyfunc keyfunc.Keyfunc
24 type msclaims_t struct {
25 /* TODO: These may be non-portable Microsoft attributes */
26 Name string `json:"name"` /* Scope: profile */
27 Email string `json:"email"` /* Scope: email */
28 jwt.RegisteredClaims
32 * Fetch the OpenID Connect configuration. The endpoint specified in
33 * the configuration is incomplete and we fetch the OpenID Connect
34 * configuration from the well-known endpoint.
35 * This seems to be supported by many authentication providers.
36 * The following work, as config.Openid.Endpoint:
37 * - https://login.microsoftonline.com/common
38 * - https://accounts.google.com/.well-known/openid-configuration
40 func get_openid_config(endpoint string) {
41 resp, err := http.Get(endpoint + "/.well-known/openid-configuration")
42 e(err)
43 defer resp.Body.Close()
44 if resp.StatusCode != 200 {
45 log.Fatal(fmt.Sprintf(
46 "Got response code %d from openid-configuration\n",
47 resp.StatusCode,
50 err = json.NewDecoder(resp.Body).Decode(&openid_configuration)
51 e(err)
53 resp, err = http.Get(openid_configuration.JwksUri)
54 e(err)
55 defer resp.Body.Close()
56 if resp.StatusCode != 200 {
57 log.Fatal(fmt.Sprintf(
58 "Got response code %d from JwksUri\n",
59 resp.StatusCode,
63 if config.Openid.Authorize != "" {
64 openid_configuration.AuthorizationEndpoint =
65 config.Openid.Authorize
68 jwks_json, err := io.ReadAll(resp.Body)
69 e(err)
72 * TODO: The key set is never updated, which is technically incorrect.
73 * We could use keyfunc's auto-update mechanism, but I'd prefer
74 * controlling when to do it manually. Remember to wrap it around a
75 * mutex or some semaphores though.
77 openid_keyfunc, err = keyfunc.NewJWKSetJSON(jwks_json)
78 e(err)
81 func generate_authorization_url() string {
83 * TODO: Handle nonces and anti-replay. Incremental nonces would be
84 * nice on memory and speed (depending on how maps are implemented in
85 * Go, hopefully it's some sort of btree), but that requires either
86 * hacky atomics or having a multiple goroutines to handle
87 * authentication, neither of which are desirable.
89 nonce := random(30)
90 return fmt.Sprintf(
91 "%s"+
92 "?client_id=%s"+
93 "&response_type=id_token"+
94 "&redirect_uri=%s%s"+
95 "&response_mode=form_post"+
96 "&scope=openid+profile+email"+
97 "&nonce=%s",
98 openid_configuration.AuthorizationEndpoint,
99 config.Openid.Client,
100 config.Url,
101 config.Openid.Redirect,
102 nonce,
106 func handle_oidc(w http.ResponseWriter, req *http.Request) {
107 if req.Method != "POST" {
108 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
109 w.WriteHeader(405)
110 w.Write([]byte(
111 "Error\n" +
112 "Only POST is allowed on the OIDC callback.\n" +
113 "Please return to the login page and retry.\n",
115 return
118 err := req.ParseForm()
119 if err != nil {
120 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
121 w.WriteHeader(400)
122 w.Write([]byte(
123 "Error\n" +
124 "Malformed form data.\n",
126 return
129 returned_error := req.PostFormValue("error")
130 if returned_error != "" {
131 returned_error_description :=
132 req.PostFormValue("error_description")
133 if returned_error_description == "" {
134 w.Header().Set(
135 "Content-Type",
136 "text/plain; charset=utf-8",
138 w.WriteHeader(400)
139 w.Write([]byte(fmt.Sprintf(
140 "Error\n%s\n",
141 returned_error,
143 return
144 } else {
145 w.Header().Set(
146 "Content-Type",
147 "text/plain; charset=utf-8",
149 w.WriteHeader(400)
150 w.Write([]byte(fmt.Sprintf(
151 "Error\n%s\n%s\n",
152 returned_error,
153 returned_error_description,
155 return
159 id_token_string := req.PostFormValue("id_token")
160 if id_token_string == "" {
161 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
162 w.WriteHeader(400)
163 w.Write([]byte(fmt.Sprintf("Error\nMissing id_token.\n")))
164 return
167 token, err := jwt.ParseWithClaims(
168 id_token_string,
169 &msclaims_t{},
170 openid_keyfunc.Keyfunc,
172 if err != nil {
173 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
174 w.WriteHeader(400)
175 w.Write([]byte(fmt.Sprintf("Error\nCannot parse claims.\n")))
176 return
179 switch {
180 case token.Valid:
181 break
182 case errors.Is(err, jwt.ErrTokenMalformed):
183 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
184 w.WriteHeader(400)
185 w.Write([]byte(fmt.Sprintf("Error\nMalformed JWT token.\n")))
186 return
187 case errors.Is(err, jwt.ErrTokenSignatureInvalid):
188 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
189 w.WriteHeader(400)
190 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWS signature.\n")))
191 return
192 case errors.Is(err, jwt.ErrTokenExpired) ||
193 errors.Is(err, jwt.ErrTokenNotValidYet):
194 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
195 w.WriteHeader(400)
196 w.Write([]byte(fmt.Sprintf(
197 "Error\n" +
198 "JWT token expired or not yet valid.\n",
200 return
201 default:
202 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
203 w.WriteHeader(400)
204 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWT token.\n")))
205 return
208 claims, claims_ok := token.Claims.(*msclaims_t)
210 if !claims_ok {
211 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
212 w.WriteHeader(400)
213 w.Write([]byte(fmt.Sprintf("Error\nCannot unpack claims.\n")))
214 return
217 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
218 w.WriteHeader(200)
219 w.Write([]byte(fmt.Sprintf(
220 "Name: %s\nEmail: %s\nSubject: %s\n",
221 claims.Name,
222 claims.Email,
223 claims.Subject,
225 return