index.tmpl: Update
[fbfp.git] / oidc.go
blobc9d0d7601b295309fc71e033eee9b9d20dedb990
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 "context"
25 "encoding/json"
26 "errors"
27 "fmt"
28 "io"
29 "net/http"
31 "github.com/MicahParks/keyfunc/v3"
32 "github.com/golang-jwt/jwt/v5"
33 "github.com/jackc/pgx/v5/pgconn"
36 var openid_configuration struct {
37 AuthorizationEndpoint string `json:"authorization_endpoint"`
38 TokenEndpoint string `json:"token_endpoint"`
39 JwksUri string `json:"jwks_uri"`
40 UserinfoEndpoint string `json:"userinfo_endpoint"`
43 var openid_keyfunc keyfunc.Keyfunc
45 type msclaims_t struct {
46 /* TODO: These may be non-portable Microsoft attributes */
47 Name string `json:"name"` /* Scope: profile */
48 Email string `json:"email"` /* Scope: email */
49 jwt.RegisteredClaims
53 * Fetch the OpenID Connect configuration. The endpoint specified in
54 * the configuration is incomplete and we fetch the OpenID Connect
55 * configuration from the well-known endpoint.
56 * This seems to be supported by many authentication providers.
57 * The following work, as config.Openid.Endpoint:
58 * - https://login.microsoftonline.com/common
59 * - https://accounts.google.com/.well-known/openid-configuration
61 func get_openid_config(endpoint string) error {
62 resp, err := http.Get(endpoint + "/.well-known/openid-configuration")
63 if err != nil {
64 return err
66 defer resp.Body.Close()
68 if resp.StatusCode != 200 {
69 return errors.New("Got non-200 response code from openid-configuration")
72 if err := json.NewDecoder(resp.Body).Decode(&openid_configuration); err != nil {
73 return err
76 resp, err = http.Get(openid_configuration.JwksUri)
77 if err != nil {
78 return err
80 defer resp.Body.Close()
82 if resp.StatusCode != 200 {
83 return errors.New("Got non-200 response code from JwksUri")
86 if config.Openid.Authorize != "" {
87 openid_configuration.AuthorizationEndpoint =
88 config.Openid.Authorize
91 jwks_json, err := io.ReadAll(resp.Body)
92 if err != nil {
93 return err
97 * TODO: The key set is never updated, which is technically incorrect.
98 * We could use keyfunc's auto-update mechanism, but I'd prefer
99 * controlling when to do it manually. Remember to wrap it around a
100 * mutex or some semaphores though.
102 openid_keyfunc, err = keyfunc.NewJWKSetJSON(jwks_json)
103 if err != nil {
104 return err
107 return nil
110 func generate_authorization_url() string {
112 * TODO: Handle nonces and anti-replay. Incremental nonces would be
113 * nice on memory and speed (depending on how maps are implemented in
114 * Go, hopefully it's some sort of btree), but that requires either
115 * hacky atomics or having a multiple goroutines to handle
116 * authentication, neither of which are desirable.
118 nonce := random(30)
119 return fmt.Sprintf(
120 "%s"+
121 "?client_id=%s"+
122 "&response_type=id_token"+
123 "&redirect_uri=%s/oidc"+
124 "&response_mode=form_post"+
125 "&scope=openid+profile+email"+
126 "&nonce=%s",
127 openid_configuration.AuthorizationEndpoint,
128 config.Openid.Client,
129 config.Url,
130 nonce,
134 func handle_oidc(w http.ResponseWriter, req *http.Request) {
135 if req.Method != "POST" {
136 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
137 w.WriteHeader(405)
138 w.Write([]byte(
139 "Error\n" +
140 "Only POST is allowed on the OIDC callback.\n" +
141 "Please return to the login page and retry.\n",
143 return
146 err := req.ParseForm()
147 if err != nil {
148 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
149 w.WriteHeader(400)
150 w.Write([]byte(fmt.Sprintf(
151 "Error\n"+
152 "Malformed form data.\n%s\n",
153 err,
155 return
158 returned_error := req.PostFormValue("error")
159 if returned_error != "" {
160 returned_error_description :=
161 req.PostFormValue("error_description")
162 if returned_error_description == "" {
163 w.Header().Set(
164 "Content-Type",
165 "text/plain; charset=utf-8",
167 w.WriteHeader(400)
168 w.Write([]byte(fmt.Sprintf(
169 "Error\n%s\n",
170 returned_error,
172 return
173 } else {
174 w.Header().Set(
175 "Content-Type",
176 "text/plain; charset=utf-8",
178 w.WriteHeader(400)
179 w.Write([]byte(fmt.Sprintf(
180 "Error\n%s\n%s\n",
181 returned_error,
182 returned_error_description,
184 return
188 id_token_string := req.PostFormValue("id_token")
189 if id_token_string == "" {
190 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
191 w.WriteHeader(400)
192 w.Write([]byte(fmt.Sprintf("Error\nMissing id_token.\n")))
193 return
196 token, err := jwt.ParseWithClaims(
197 id_token_string,
198 &msclaims_t{},
199 openid_keyfunc.Keyfunc,
201 if err != nil {
202 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
203 w.WriteHeader(400)
204 w.Write([]byte(fmt.Sprintf(
205 "Error\n"+
206 "Cannot parse claims.\n%s\n",
207 err,
209 return
212 switch {
213 case token.Valid:
214 break
215 case errors.Is(err, jwt.ErrTokenMalformed):
216 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
217 w.WriteHeader(400)
218 w.Write([]byte(fmt.Sprintf("Error\nMalformed JWT token.\n")))
219 return
220 case errors.Is(err, jwt.ErrTokenSignatureInvalid):
221 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
222 w.WriteHeader(400)
223 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWS signature.\n")))
224 return
225 case errors.Is(err, jwt.ErrTokenExpired) ||
226 errors.Is(err, jwt.ErrTokenNotValidYet):
227 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
228 w.WriteHeader(400)
229 w.Write([]byte(fmt.Sprintf(
230 "Error\n" +
231 "JWT token expired or not yet valid.\n",
233 return
234 default:
235 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
236 w.WriteHeader(400)
237 w.Write([]byte(fmt.Sprintf("Error\nInvalid JWT token.\n")))
238 return
241 claims, claims_ok := token.Claims.(*msclaims_t)
243 if !claims_ok {
244 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
245 w.WriteHeader(400)
246 w.Write([]byte(fmt.Sprintf("Error\nCannot unpack claims.\n")))
247 return
250 cookie_value := random(20)
252 cookie := http.Cookie{
253 Name: "session",
254 Value: cookie_value,
255 SameSite: http.SameSiteLaxMode,
256 HttpOnly: true,
257 Secure: config.Prod,
260 http.SetCookie(w, &cookie)
261 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
263 _, err = db.Exec(
264 context.Background(),
265 "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
266 claims.Subject,
267 claims.Name,
268 claims.Email,
270 if err != nil {
271 var pgErr *pgconn.PgError
272 if errors.As(err, &pgErr) {
273 if pgErr.Code == "23505" {
274 _, err := db.Exec(
275 context.Background(),
276 "UPDATE users SET (name, email) = ($1, $2) WHERE id = $3",
277 claims.Name,
278 claims.Email,
279 claims.Subject,
281 if err != nil {
282 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
283 w.WriteHeader(500)
284 w.Write([]byte(fmt.Sprintf("Error\nDatabase error while updating your account.\n%s\n", err)))
285 return
288 } else {
289 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
290 w.WriteHeader(500)
291 w.Write([]byte(fmt.Sprintf("Error\nDatabase error while attempting to insert account info.\n%s\n", err)))
292 return
296 _, err = db.Exec(
297 context.Background(),
298 "INSERT INTO sessions(userid, cookie, expr) VALUES ($1, $2, $3)",
299 claims.Subject,
300 cookie_value,
301 1881839332, /* TODO */
303 if err != nil {
304 var pgErr *pgconn.PgError
305 if errors.As(err, &pgErr) && pgErr.Code == "23505" {
306 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
307 w.WriteHeader(500)
308 w.Write([]byte(fmt.Sprintf("Error\nCookie collision! Could you try signing in again?\n%s\n", err)))
309 return
310 } else {
311 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
312 w.WriteHeader(500)
313 w.Write([]byte(fmt.Sprintf("Error\nDatabase error while attempting to insert session info.\n%s\n", err)))
314 return
318 http.Redirect(w, req, "/", 303)
320 return