Configuration changes
[fbfp.git] / oidc.go
blobf23381fbd4595e98307a0a4de684d9953791333f
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/oidc"+
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 nonce,
105 func handle_oidc(w http.ResponseWriter, req *http.Request) {
106 if req.Method != "POST" {
107 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
108 w.WriteHeader(405)
109 w.Write([]byte(
110 "Error\n" +
111 "Only POST is allowed on the OIDC callback.\n" +
112 "Please return to the login page and retry.\n",
114 return
117 err := req.ParseForm()
118 if err != nil {
119 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
120 w.WriteHeader(400)
121 w.Write([]byte(
122 "Error\n" +
123 "Malformed form data.\n",
125 return
128 returned_error := req.PostFormValue("error")
129 if returned_error != "" {
130 returned_error_description :=
131 req.PostFormValue("error_description")
132 if returned_error_description == "" {
133 w.Header().Set(
134 "Content-Type",
135 "text/plain; charset=utf-8",
137 w.WriteHeader(400)
138 w.Write([]byte(fmt.Sprintf(
139 "Error\n%s\n",
140 returned_error,
142 return
143 } else {
144 w.Header().Set(
145 "Content-Type",
146 "text/plain; charset=utf-8",
148 w.WriteHeader(400)
149 w.Write([]byte(fmt.Sprintf(
150 "Error\n%s\n%s\n",
151 returned_error,
152 returned_error_description,
154 return
158 id_token_string := req.PostFormValue("id_token")
159 if id_token_string == "" {
160 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
161 w.WriteHeader(400)
162 w.Write([]byte(fmt.Sprintf("Error\nMissing id_token.\n")))
163 return
166 token, err := jwt.ParseWithClaims(
167 id_token_string,
168 &msclaims_t{},
169 openid_keyfunc.Keyfunc,
171 if err != nil {
172 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
173 w.WriteHeader(400)
174 w.Write([]byte(fmt.Sprintf("Error\nCannot parse claims.\n")))
175 return
178 switch {
179 case token.Valid:
180 break
181 case errors.Is(err, jwt.ErrTokenMalformed):
182 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
183 w.WriteHeader(400)
184 w.Write([]byte(fmt.Sprintf("Error\nMalformed JWT token.\n")))
185 return
186 case errors.Is(err, jwt.ErrTokenSignatureInvalid):
187 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
188 w.WriteHeader(400)
189 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWS signature.\n")))
190 return
191 case errors.Is(err, jwt.ErrTokenExpired) ||
192 errors.Is(err, jwt.ErrTokenNotValidYet):
193 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
194 w.WriteHeader(400)
195 w.Write([]byte(fmt.Sprintf(
196 "Error\n" +
197 "JWT token expired or not yet valid.\n",
199 return
200 default:
201 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
202 w.WriteHeader(400)
203 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWT token.\n")))
204 return
207 claims, claims_ok := token.Claims.(*msclaims_t)
209 if !claims_ok {
210 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
211 w.WriteHeader(400)
212 w.Write([]byte(fmt.Sprintf("Error\nCannot unpack claims.\n")))
213 return
216 cookie_value := random(20)
218 cookie := http.Cookie{
219 Name: "session",
220 Value: cookie_value,
221 SameSite: http.SameSiteLaxMode,
222 HttpOnly: true,
225 http.SetCookie(w, &cookie)
226 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
227 w.WriteHeader(200)
228 w.Write([]byte(fmt.Sprintf(
229 "Name: %s\nEmail: %s\nSubject: %s\nCookie: %s\n",
230 claims.Name,
231 claims.Email,
232 claims.Subject,
233 cookie_value,
235 return