db.go
[fbfp.git] / oidc.go
blob0f8c54d0191ad692e13f09c8e4dca06a92caaf1a
1 /*
2 * OpenID Connect for fbfp.
4 * Copyright (C) 2024 Runxi Yu <https://runxiyu.org>
5 * SPDX-License-Identifier: AGPL-3.0-or-later
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as published by
9 * the Free Software Foundation, either version 3 of the License, or
10 * (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 package main
23 import (
24 "encoding/json"
25 "errors"
26 "fmt"
27 "io"
28 "log"
29 "net/http"
31 "github.com/MicahParks/keyfunc/v3"
32 "github.com/golang-jwt/jwt/v5"
35 var openid_configuration struct {
36 AuthorizationEndpoint string `json:"authorization_endpoint"`
37 TokenEndpoint string `json:"token_endpoint"`
38 JwksUri string `json:"jwks_uri"`
39 UserinfoEndpoint string `json:"userinfo_endpoint"`
42 var openid_keyfunc keyfunc.Keyfunc
44 type msclaims_t struct {
45 /* TODO: These may be non-portable Microsoft attributes */
46 Name string `json:"name"` /* Scope: profile */
47 Email string `json:"email"` /* Scope: email */
48 jwt.RegisteredClaims
52 * Fetch the OpenID Connect configuration. The endpoint specified in
53 * the configuration is incomplete and we fetch the OpenID Connect
54 * configuration from the well-known endpoint.
55 * This seems to be supported by many authentication providers.
56 * The following work, as config.Openid.Endpoint:
57 * - https://login.microsoftonline.com/common
58 * - https://accounts.google.com/.well-known/openid-configuration
60 func get_openid_config(endpoint string) {
61 resp, err := http.Get(endpoint + "/.well-known/openid-configuration")
62 e(err)
63 defer resp.Body.Close()
64 if resp.StatusCode != 200 {
65 log.Fatal(fmt.Sprintf(
66 "Got response code %d from openid-configuration\n",
67 resp.StatusCode,
70 err = json.NewDecoder(resp.Body).Decode(&openid_configuration)
71 e(err)
73 resp, err = http.Get(openid_configuration.JwksUri)
74 e(err)
75 defer resp.Body.Close()
76 if resp.StatusCode != 200 {
77 log.Fatal(fmt.Sprintf(
78 "Got response code %d from JwksUri\n",
79 resp.StatusCode,
83 if config.Openid.Authorize != "" {
84 openid_configuration.AuthorizationEndpoint =
85 config.Openid.Authorize
88 jwks_json, err := io.ReadAll(resp.Body)
89 e(err)
92 * TODO: The key set is never updated, which is technically incorrect.
93 * We could use keyfunc's auto-update mechanism, but I'd prefer
94 * controlling when to do it manually. Remember to wrap it around a
95 * mutex or some semaphores though.
97 openid_keyfunc, err = keyfunc.NewJWKSetJSON(jwks_json)
98 e(err)
101 func generate_authorization_url() string {
103 * TODO: Handle nonces and anti-replay. Incremental nonces would be
104 * nice on memory and speed (depending on how maps are implemented in
105 * Go, hopefully it's some sort of btree), but that requires either
106 * hacky atomics or having a multiple goroutines to handle
107 * authentication, neither of which are desirable.
109 nonce := random(30)
110 return fmt.Sprintf(
111 "%s"+
112 "?client_id=%s"+
113 "&response_type=id_token"+
114 "&redirect_uri=%s/oidc"+
115 "&response_mode=form_post"+
116 "&scope=openid+profile+email"+
117 "&nonce=%s",
118 openid_configuration.AuthorizationEndpoint,
119 config.Openid.Client,
120 config.Url,
121 nonce,
125 func handle_oidc(w http.ResponseWriter, req *http.Request) {
126 if req.Method != "POST" {
127 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
128 w.WriteHeader(405)
129 w.Write([]byte(
130 "Error\n" +
131 "Only POST is allowed on the OIDC callback.\n" +
132 "Please return to the login page and retry.\n",
134 return
137 err := req.ParseForm()
138 if err != nil {
139 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
140 w.WriteHeader(400)
141 w.Write([]byte(
142 "Error\n" +
143 "Malformed form data.\n",
145 return
148 returned_error := req.PostFormValue("error")
149 if returned_error != "" {
150 returned_error_description :=
151 req.PostFormValue("error_description")
152 if returned_error_description == "" {
153 w.Header().Set(
154 "Content-Type",
155 "text/plain; charset=utf-8",
157 w.WriteHeader(400)
158 w.Write([]byte(fmt.Sprintf(
159 "Error\n%s\n",
160 returned_error,
162 return
163 } else {
164 w.Header().Set(
165 "Content-Type",
166 "text/plain; charset=utf-8",
168 w.WriteHeader(400)
169 w.Write([]byte(fmt.Sprintf(
170 "Error\n%s\n%s\n",
171 returned_error,
172 returned_error_description,
174 return
178 id_token_string := req.PostFormValue("id_token")
179 if id_token_string == "" {
180 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
181 w.WriteHeader(400)
182 w.Write([]byte(fmt.Sprintf("Error\nMissing id_token.\n")))
183 return
186 token, err := jwt.ParseWithClaims(
187 id_token_string,
188 &msclaims_t{},
189 openid_keyfunc.Keyfunc,
191 if err != nil {
192 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
193 w.WriteHeader(400)
194 w.Write([]byte(fmt.Sprintf("Error\nCannot parse claims.\n")))
195 return
198 switch {
199 case token.Valid:
200 break
201 case errors.Is(err, jwt.ErrTokenMalformed):
202 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
203 w.WriteHeader(400)
204 w.Write([]byte(fmt.Sprintf("Error\nMalformed JWT token.\n")))
205 return
206 case errors.Is(err, jwt.ErrTokenSignatureInvalid):
207 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
208 w.WriteHeader(400)
209 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWS signature.\n")))
210 return
211 case errors.Is(err, jwt.ErrTokenExpired) ||
212 errors.Is(err, jwt.ErrTokenNotValidYet):
213 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
214 w.WriteHeader(400)
215 w.Write([]byte(fmt.Sprintf(
216 "Error\n" +
217 "JWT token expired or not yet valid.\n",
219 return
220 default:
221 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
222 w.WriteHeader(400)
223 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWT token.\n")))
224 return
227 claims, claims_ok := token.Claims.(*msclaims_t)
229 if !claims_ok {
230 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
231 w.WriteHeader(400)
232 w.Write([]byte(fmt.Sprintf("Error\nCannot unpack claims.\n")))
233 return
236 cookie_value := random(20)
238 cookie := http.Cookie{
239 Name: "session",
240 Value: cookie_value,
241 SameSite: http.SameSiteLaxMode,
242 HttpOnly: true,
245 http.SetCookie(w, &cookie)
246 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
247 w.WriteHeader(200)
248 w.Write([]byte(fmt.Sprintf(
249 "Name: %s\nEmail: %s\nSubject: %s\nCookie: %s\n",
250 claims.Name,
251 claims.Email,
252 claims.Subject,
253 cookie_value,
255 return