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/>.
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 */
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")
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 {
76 resp
, err
= http
.Get(openid_configuration
.JwksUri
)
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
)
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
)
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.
122 "&response_type=id_token"+
123 "&redirect_uri=%s/oidc"+
124 "&response_mode=form_post"+
125 "&scope=openid+profile+email"+
127 openid_configuration
.AuthorizationEndpoint
,
128 config
.Openid
.Client
,
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")
140 "Only POST is allowed on the OIDC callback.\n" +
141 "Please return to the login page and retry.\n",
146 err
:= req
.ParseForm()
148 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
150 w
.Write([]byte(fmt
.Sprintf(
152 "Malformed form data.\n%s\n",
158 returned_error
:= req
.PostFormValue("error")
159 if returned_error
!= "" {
160 returned_error_description
:=
161 req
.PostFormValue("error_description")
162 if returned_error_description
== "" {
165 "text/plain; charset=utf-8",
168 w
.Write([]byte(fmt
.Sprintf(
176 "text/plain; charset=utf-8",
179 w
.Write([]byte(fmt
.Sprintf(
182 returned_error_description
,
188 id_token_string
:= req
.PostFormValue("id_token")
189 if id_token_string
== "" {
190 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
192 w
.Write([]byte(fmt
.Sprintf("Error\nMissing id_token.\n")))
196 token
, err
:= jwt
.ParseWithClaims(
199 openid_keyfunc
.Keyfunc
,
202 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
204 w
.Write([]byte(fmt
.Sprintf(
206 "Cannot parse claims.\n%s\n",
215 case errors
.Is(err
, jwt
.ErrTokenMalformed
):
216 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
218 w
.Write([]byte(fmt
.Sprintf("Error\nMalformed JWT token.\n")))
220 case errors
.Is(err
, jwt
.ErrTokenSignatureInvalid
):
221 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
223 w
.Write([]byte(fmt
.Sprintf("Error\nInvalid JWS signature.\n")))
225 case errors
.Is(err
, jwt
.ErrTokenExpired
) ||
226 errors
.Is(err
, jwt
.ErrTokenNotValidYet
):
227 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
229 w
.Write([]byte(fmt
.Sprintf(
231 "JWT token expired or not yet valid.\n",
235 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
237 w
.Write([]byte(fmt
.Sprintf("Error\nInvalid JWT token.\n")))
241 claims
, claims_ok
:= token
.Claims
.(*msclaims_t
)
244 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
246 w
.Write([]byte(fmt
.Sprintf("Error\nCannot unpack claims.\n")))
250 cookie_value
:= random(20)
252 cookie
:= http
.Cookie
{
255 SameSite
: http
.SameSiteLaxMode
,
260 http
.SetCookie(w
, &cookie
)
261 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
264 context
.Background(),
265 "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
271 var pgErr
*pgconn
.PgError
272 if errors
.As(err
, &pgErr
) {
273 if pgErr
.Code
== "23505" {
275 context
.Background(),
276 "UPDATE users SET (name, email) = ($1, $2) WHERE id = $3",
282 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
284 w
.Write([]byte(fmt
.Sprintf("Error\nDatabase error while updating your account.\n%s\n", err
)))
289 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
291 w
.Write([]byte(fmt
.Sprintf("Error\nDatabase error while attempting to insert account info.\n%s\n", err
)))
297 context
.Background(),
298 "INSERT INTO sessions(userid, cookie, expr) VALUES ($1, $2, $3)",
301 1881839332, /* TODO */
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")
308 w
.Write([]byte(fmt
.Sprintf("Error\nCookie collision! Could you try signing in again?\n%s\n", err
)))
311 w
.Header().Set("Content-Type", "text/plain; charset=utf-8")
313 w
.Write([]byte(fmt
.Sprintf("Error\nDatabase error while attempting to insert session info.\n%s\n", err
)))
318 http
.Redirect(w
, req
, "/", 303)