diff --git a/docs/install/configuration.md b/docs/install/configuration.md index f90466a8..ad6dd3a4 100644 --- a/docs/install/configuration.md +++ b/docs/install/configuration.md @@ -41,6 +41,28 @@ To be able to send email from your reMarkable, fill the following variables: | `RM_SMTP_STARTTLS` | use starttls command, should be combined with NOTLS. in most cases port 587 should be used | | `RM_SMTP_INSECURE_TLS` | If set, don't check the server certificate (not recommended) | +## OIDC authentication + +To use OpenID Connect for login, fill the following variables. `RM_HTTPS_COOKIE=true` is also required. + +| Variable name | Description | +|---|---| +| `OIDC_PROVIDER_URL` | Provider discovery URL (the base URL, not `/.well-known/openid-configuration`). Example: `https://sso.example.com` | +| `OIDC_CLIENT_ID` | OAuth2 client ID | +| `OIDC_CLIENT_SECRET` | OAuth2 client secret | +| `OIDC_REDIRECT_URL` | Callback URL, must end with `/ui/api/oidc/callback`. Example: `https://your-domain.com/ui/api/oidc/callback` | +| `OIDC_USERID_CLAIM` | Optional: OIDC claim to use as the user ID (default: `preferred_username`). Supports `preferred_username`, `sub`, `email`, other string claims, and dotted paths such as `custom.userid`. If the configured claim is empty, login falls back to `email`. Whenever the actual identifier is `email`, email verification is enforced unless `OIDC_ALLOW_UNVERIFIED_EMAIL=true`. | +| `OIDC_DISABLE_LOCAL_LOGIN` | Optional: set to `true` to hide the password form, disable the registration endpoint, and auto-redirect `/login` to OIDC. This requires OIDC for all users (default: `false`). When enabled, users visit the login page and are immediately redirected to your OIDC provider. | +| `OIDC_ADMIN_CLAIM` | Optional: dotted path to the claim that holds admin role values (e.g. `groups`). If unset, no OIDC user is granted admin. The claim is read from the ID token — see `OIDC_EXTRA_SCOPES` if your provider requires a scope to include it. | +| `OIDC_ADMIN_CLAIM_VALUE` | Optional: value in that claim that grants admin (e.g. `admin`). Re-evaluated on every login. If unset, no OIDC user is granted admin. | +| `OIDC_EXTRA_SCOPES` | Optional: space-separated extra OAuth2 scopes. `openid`, `email`, and `profile` are always requested (default: none). Use this if your provider requires a scope to include role claims in the ID token (e.g. `groups` for Okta). | +| `OIDC_DISPLAY_NAME` | Optional: custom label for the OIDC login button (default: `Login with OIDC`) | +| `OIDC_ALLOW_UNVERIFIED_EMAIL` | Optional: set to `true` to allow login when the provider's `email_verified` claim is missing or `false`. Leave unset for the secure default — logins with an unverified email are rejected to prevent account takeover via an unverified address. Applies whenever the actual user ID is `email`, including fallback from another claim (default: `false`). | + +### Provider examples + +- [Authelia](oidc/authelia.md) + ## Screen sharing Screen sharing streams your tablet display to a browser via WebRTC. There are two signaling modes depending on your tablet's software version. diff --git a/docs/install/oidc/authelia.md b/docs/install/oidc/authelia.md new file mode 100644 index 00000000..8c0a569e --- /dev/null +++ b/docs/install/oidc/authelia.md @@ -0,0 +1,76 @@ +# OIDC with Authelia + +This guide shows how to configure [Authelia](https://www.authelia.com/) as the identity provider for rmfakecloud. + +See the [OIDC configuration reference](../configuration.md#oidc-authentication) for the full list of available environment variables. + +## Authelia client configuration + +Add a client entry to the `identity_providers.oidc.clients` section of your Authelia configuration. Generate a hashed secret with: + +```bash +authelia crypto hash generate argon2 --random --random.length 64 --random.charset alphanumeric +``` + +This prints a plaintext secret and its hash. Use the hash in Authelia's config and the plaintext value in `OIDC_CLIENT_SECRET`. + +Authelia 4.38+ does not include the `groups` claim in the ID token by default, even when the `groups` scope is requested. You must define a `claims_policy` that explicitly lists `groups` in `id_token`, and reference it from the client. Add the following to the `identity_providers.oidc` section of your Authelia configuration (not inside `clients:`): + +```yaml +identity_providers: + oidc: + claims_policies: + with_groups: + id_token: + - email + - email_verified + - groups + - preferred_username + - name +``` + +Then add the client entry to `identity_providers.oidc.clients`: + +```yaml +identity_providers: + oidc: + clients: + - client_id: 'rmfakecloud' + client_name: 'rmfakecloud' + client_secret: '$argon2id$v=19$...' # hashed secret from above + public: false + authorization_policy: 'one_factor' # or 'two_factor' for stricter security + consent_mode: implicit + claims_policy: 'with_groups' + redirect_uris: + - 'https://your-domain.com/ui/api/oidc/callback' + scopes: + - 'openid' + - 'email' + - 'profile' + - 'groups' + userinfo_signed_response_alg: 'none' + token_endpoint_auth_method: 'client_secret_basic' +``` + +The `claims_policy: 'with_groups'` is what causes the `groups` claim to appear in the ID token that rmfakecloud reads. Without it, the groups claim is only available at the userinfo endpoint and `OIDC_ADMIN_CLAIM=groups` will not work. + +## Admin group + +Create a group named `rmfakecloud-admins` in your Authelia user database and add the users who should have admin access. + +## rmfakecloud environment variables + +```env +OIDC_PROVIDER_URL=https://auth.example.com +OIDC_CLIENT_ID=rmfakecloud +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URL=https://your-domain.com/ui/api/oidc/callback +RM_HTTPS_COOKIE=true # required: OIDC flow cookies carry the Secure flag +OIDC_ADMIN_CLAIM=groups +OIDC_ADMIN_CLAIM_VALUE=rmfakecloud-admins +OIDC_EXTRA_SCOPES=groups +OIDC_DISPLAY_NAME=Login with Authelia +``` + +Replace `auth.example.com` with your Authelia hostname and `your-domain.com` with the hostname of your rmfakecloud instance. diff --git a/go.mod b/go.mod index 65d8dffa..c8facd0b 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/ddvk/rmfakecloud -go 1.23.3 - -toolchain go1.24.1 +go 1.25.0 require ( github.com/apognu/gocal v0.9.1 + github.com/coreos/go-oidc/v3 v3.19.0 github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 github.com/gin-gonic/gin v1.9.1 @@ -22,6 +21,7 @@ require ( github.com/studio-b12/gowebdav v0.9.0 github.com/unidoc/unipdf/v3 v3.56.0 golang.org/x/crypto v0.36.0 + golang.org/x/oauth2 v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,11 +36,11 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -65,11 +65,9 @@ require ( golang.org/x/arch v0.7.0 // indirect golang.org/x/image v0.18.0 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.18.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect ) diff --git a/go.sum b/go.sum index c3e2fdd0..07fcbf57 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 h1:m+Fkk9QEMuV6Z1ithqqYogOHV7Pl6rMKe34NBTJTS/c= github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22/go.mod h1:jXqs4TJbb7Xtl0FwUgBaOXty8edb/61H37U4D9E5EQE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -85,6 +87,8 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -121,10 +125,6 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -134,7 +134,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -255,7 +254,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -269,7 +267,6 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -307,7 +304,6 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -335,8 +331,6 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -345,8 +339,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -356,7 +350,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -384,28 +377,20 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -452,7 +437,6 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201223200349-f6952e403d3f/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -481,8 +465,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -534,8 +516,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 2b7954af..04467683 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,7 +10,9 @@ import ( "net/url" "os" "path/filepath" + "slices" "strconv" + "strings" "github.com/ddvk/rmfakecloud/internal/email" log "github.com/sirupsen/logrus" @@ -81,12 +83,25 @@ const ( envMQTTPort = "MQTT_PORT" envICEServers = "ICE_SERVERS" envHashSchemaVersion = "HASH_SCHEMA_VERSION" + + // oidc + EnvOIDCProviderURL = "OIDC_PROVIDER_URL" + EnvOIDCClientID = "OIDC_CLIENT_ID" + EnvOIDCClientSecret = "OIDC_CLIENT_SECRET" + EnvOIDCRedirectURL = "OIDC_REDIRECT_URL" + EnvOIDCAdminClaim = "OIDC_ADMIN_CLAIM" + EnvOIDCAdminClaimValue = "OIDC_ADMIN_CLAIM_VALUE" + EnvOIDCExtraScopes = "OIDC_EXTRA_SCOPES" + EnvOIDCDisplayName = "OIDC_DISPLAY_NAME" + EnvOIDCUserIDClaim = "OIDC_USERID_CLAIM" + EnvOIDCAllowUnverifiedEmail = "OIDC_ALLOW_UNVERIFIED_EMAIL" + DefaultOIDCUserIDClaim = "preferred_username" ) // Config config type Config struct { - Port string - StorageURL string + Port string + StorageURL string //only https CloudHost string DataDir string @@ -106,6 +121,46 @@ type Config struct { MQTTPort string ICEServers []interface{} HashSchemaVersion string + + OIDC OIDCConfig +} + +// OIDCConfig holds all OIDC-related settings. The zero value means OIDC is disabled. +type OIDCConfig struct { + ProviderURL string + ClientID string + ClientSecret string + RedirectURL string + AdminClaim string + AdminClaimValue string + ExtraScopes []string + DisplayName string + UserIDClaim string + AllowUnverifiedEmail bool +} + +// Enabled returns true when all required OIDC fields are set. +func (o *OIDCConfig) Enabled() bool { + return o.ProviderURL != "" && o.ClientID != "" && + o.ClientSecret != "" && o.RedirectURL != "" +} + +// partiallyConfigured detects any OIDC env var set without the full required set. +func (o *OIDCConfig) partiallyConfigured() bool { + any := o.ProviderURL != "" || o.ClientID != "" || + o.ClientSecret != "" || o.RedirectURL != "" + return any && !o.Enabled() +} + +// Scopes returns the full scopes list for the OIDC authorization request. +func (o *OIDCConfig) Scopes() []string { + scopes := []string{"openid", "email", "profile"} + for _, s := range o.ExtraScopes { + if !slices.Contains(scopes, s) { + scopes = append(scopes, s) + } + } + return scopes } // Verify verify @@ -142,6 +197,21 @@ func (cfg *Config) Verify() { } else { log.Info("No ICE servers configured - screenshare will only work on local networks") } + + if cfg.OIDC.partiallyConfigured() { + log.Fatal("OIDC is partially configured; set all of: ", + EnvOIDCProviderURL, ", ", EnvOIDCClientID, ", ", + EnvOIDCClientSecret, ", ", EnvOIDCRedirectURL) + } + if cfg.OIDC.Enabled() { + if !cfg.HTTPSCookie { + log.Fatal("OIDC requires secure cookies; set ", envHTTPSCookie, "=true (OIDC flow cookies must not travel over plain HTTP)") + } + if cfg.OIDC.AdminClaim == "" { + log.Warn("OIDC enabled and ", EnvOIDCAdminClaim, " is not set; all OIDC users will be non-admin") + } + log.Info("OIDC enabled, provider: ", cfg.OIDC.ProviderURL) + } } // FromEnv config from environment values @@ -264,6 +334,32 @@ func FromEnv() *Config { log.Fatalf("%s must be either '3' or '4', got: %s", envHashSchemaVersion, hashSchemaVersion) } + oidcAllowUnverifiedEmail, _ := strconv.ParseBool(os.Getenv(EnvOIDCAllowUnverifiedEmail)) + + // OIDCUserIDClaim is always set here; callers must not re-apply the default. + oidcUserIDClaim := os.Getenv(EnvOIDCUserIDClaim) + if oidcUserIDClaim == "" { + oidcUserIDClaim = DefaultOIDCUserIDClaim + } + + var oidcExtraScopes []string + if raw := os.Getenv(EnvOIDCExtraScopes); raw != "" { + for _, s := range strings.Fields(raw) { + oidcExtraScopes = append(oidcExtraScopes, s) + } + } + + oidcRedirectURL := os.Getenv(EnvOIDCRedirectURL) + if oidcRedirectURL != "" { + u, err := url.Parse(oidcRedirectURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + log.Fatalf("%s '%s' cannot be parsed or is missing http/https scheme", EnvOIDCRedirectURL, oidcRedirectURL) + } + if !strings.HasSuffix(u.Path, "/ui/api/oidc/callback") { + log.Warnf("%s does not end with /ui/api/oidc/callback — verify this matches your IdP client config", EnvOIDCRedirectURL) + } + } + cfg := Config{ Port: port, StorageURL: uploadURL, @@ -283,6 +379,19 @@ func FromEnv() *Config { MQTTPort: mqttPort, ICEServers: iceServers, HashSchemaVersion: hashSchemaVersion, + OIDC: OIDCConfig{ + ProviderURL: os.Getenv(EnvOIDCProviderURL), + ClientID: os.Getenv(EnvOIDCClientID), + ClientSecret: os.Getenv(EnvOIDCClientSecret), + RedirectURL: oidcRedirectURL, + AdminClaim: os.Getenv(EnvOIDCAdminClaim), + AdminClaimValue: os.Getenv(EnvOIDCAdminClaimValue), + ExtraScopes: oidcExtraScopes, + DisplayName: os.Getenv(EnvOIDCDisplayName), + // OIDCUserIDClaim is always set here; callers must not re-apply the default. + UserIDClaim: oidcUserIDClaim, + AllowUnverifiedEmail: oidcAllowUnverifiedEmail, + }, } return &cfg } @@ -329,6 +438,19 @@ myScript hwr (needs a developer account): %s %s override the language specified in myScript requests %s custom myScript host URL (default: https://cloud.myscript.com) + +OIDC authentication (optional, Authorization Code + PKCE): + %s OpenID Connect provider discovery URL (required to enable OIDC) + %s OIDC client ID (required) + %s OIDC client secret (required) + %s callback URL — must end with /ui/api/oidc/callback (required) + %s claim to use as userid (default: preferred_username; falls back to verified email) + %s dotted claim path that holds the admin role (e.g. realm_access.roles) + %s value in that claim that grants admin (e.g. admin) + %s space-separated extra OAuth2 scopes to request + %s custom label for the OIDC login button (default: "Login with OIDC") + %s allow login when email_verified is missing/false (default: false, insecure) + note: %s must be set to true when OIDC is enabled `, envJWTSecretKey, EnvStorageURL, @@ -361,5 +483,17 @@ myScript hwr (needs a developer account): envHwrHmac, envHwrLangOverride, envHwrHost, + + EnvOIDCProviderURL, + EnvOIDCClientID, + EnvOIDCClientSecret, + EnvOIDCRedirectURL, + EnvOIDCUserIDClaim, + EnvOIDCAdminClaim, + EnvOIDCAdminClaimValue, + EnvOIDCExtraScopes, + EnvOIDCDisplayName, + EnvOIDCAllowUnverifiedEmail, + envHTTPSCookie, ) } diff --git a/internal/model/user.go b/internal/model/user.go index 62ab58e7..514af559 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -125,6 +125,13 @@ func sanitizeEmail(email string) string { return emailWhiteList.ReplaceAllString(email, "") } +// NormalizeUserID returns the canonical user key for any identifier (email, username, sub, etc.): +// lowercase, trim whitespace, then strip characters outside [a-zA-Z0-9.@\-_]. +// Use this for both storing and looking up user IDs so they are always consistent. +func NormalizeUserID(id string) string { + return sanitizeEmail(strings.ToLower(strings.TrimSpace(id))) +} + // NewUser create a new user object func NewUser(userID string, rawPassword string) (*User, error) { password, err := hashPassword(rawPassword) @@ -132,7 +139,7 @@ func NewUser(userID string, rawPassword string) (*User, error) { return nil, err } - sanitizedID := sanitizeEmail(userID) + sanitizedID := NormalizeUserID(userID) return &User{ ID: sanitizedID, Email: sanitizedID, diff --git a/internal/storage/fs/userstorage.go b/internal/storage/fs/userstorage.go index 20b2fe2d..c1357308 100644 --- a/internal/storage/fs/userstorage.go +++ b/internal/storage/fs/userstorage.go @@ -8,6 +8,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/config" "github.com/ddvk/rmfakecloud/internal/model" + "github.com/ddvk/rmfakecloud/internal/storage" log "github.com/sirupsen/logrus" ) @@ -40,6 +41,9 @@ func (fs *FileSystemStorage) GetUser(uid string) (user *model.User, err error) { profilePath := fs.getPathFromUser(uid, profileName) _, err = os.Stat(profilePath) if err != nil { + if os.IsNotExist(err) { + return nil, storage.ErrUserNotFound + } return } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 21f29dd7..38387671 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,6 +1,7 @@ package storage import ( + "errors" "io" "time" @@ -9,6 +10,9 @@ import ( "github.com/ddvk/rmfakecloud/internal/model" ) +// ErrUserNotFound is returned by UserStorer.GetUser when no user with the given ID exists. +var ErrUserNotFound = errors.New("user not found") + // ExportOption type of export type ExportOption int diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 54c5b579..462b23e9 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -63,13 +63,14 @@ func (app *ReactAppWrapper) register(c *gin.Context) { } // Check this user doesn't already exist - _, err := app.userStorer.GetUser(form.Email) + email := model.NormalizeUserID(form.Email) + _, err := app.userStorer.GetUser(email) if err == nil { badReq(c, "already taken") return } - user, err := model.NewUser(form.Email, form.Password) + user, err := model.NewUser(email, form.Password) if err != nil { log.Error(err) c.AbortWithStatus(http.StatusInternalServerError) @@ -96,7 +97,7 @@ func (app *ReactAppWrapper) login(c *gin.Context) { // not really thread safe if app.cfg.CreateFirstUser { log.Info("Creating an admin user") - user, err := model.NewUser(form.Email, form.Password) + user, err := model.NewUser(model.NormalizeUserID(form.Email), form.Password) if err != nil { log.Error("[login]", err) c.AbortWithStatus(http.StatusInternalServerError) @@ -113,7 +114,7 @@ func (app *ReactAppWrapper) login(c *gin.Context) { } // Try to find the user - user, err := app.userStorer.GetUser(form.Email) + user, err := app.userStorer.GetUser(model.NormalizeUserID(form.Email)) if err != nil { log.Error(uiLogger, err, " cannot load user, login failed ip: ", c.ClientIP()) c.AbortWithStatus(http.StatusUnauthorized) @@ -130,6 +131,18 @@ func (app *ReactAppWrapper) login(c *gin.Context) { return } + if _, err := app.issueWebSession(c, user); err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Status(http.StatusOK) +} + +// issueWebSession builds a WebUserClaims JWT for user, sets the auth cookie, and +// returns the signed token string. It is the sole place expiry, scopes, and cookie +// attributes are defined for UI sessions — shared by password login and OIDC login. +func (app *ReactAppWrapper) issueWebSession(c *gin.Context, user *model.User) (string, error) { scopes := "" if user.Sync15 { scopes = isSync15Key @@ -154,17 +167,13 @@ func (app *ReactAppWrapper) login(c *gin.Context) { } tokenString, err := common.SignClaims(claims, app.cfg.JWTSecretKey) - if err != nil { - log.Error(err) - c.AbortWithStatus(http.StatusInternalServerError) - return + return "", err } - log.Debug("cookie expires after: ", expiresAfter) + c.SetSameSite(http.SameSiteStrictMode) c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) - - c.String(http.StatusOK, tokenString) + return tokenString, nil } func (app *ReactAppWrapper) changePassword(c *gin.Context) { diff --git a/internal/ui/middleware.go b/internal/ui/middleware.go index ec457b78..437bc62a 100644 --- a/internal/ui/middleware.go +++ b/internal/ui/middleware.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "net/http" "slices" "strings" @@ -14,11 +15,35 @@ const ( backendVersionKey string = "BackendVersion" ) +// parseWebSessionCookie reads the auth cookie and returns verified WebUserClaims. +// Returns an error if the cookie is missing, unparseable, or carries the wrong audience. +func (app *ReactAppWrapper) parseWebSessionCookie(c *gin.Context) (*WebUserClaims, error) { + token, err := c.Cookie(cookieName) + if err != nil { + return nil, err + } + claims := &WebUserClaims{} + if err := common.ClaimsFromToken(claims, token, app.cfg.JWTSecretKey); err != nil { + return nil, err + } + if !slices.Contains(claims.Audience, WebUsage) { + return nil, errors.New("wrong token audience") + } + return claims, nil +} + // IsAdmin checks if admin func IsAdmin(c *gin.Context) bool { return c.GetBool(AdminRole) } +// webAuthenticated reports whether the request carries a valid web session cookie. +// Used to decide server-side redirects without aborting the request. +func (app *ReactAppWrapper) webAuthenticated(c *gin.Context) bool { + _, err := app.parseWebSessionCookie(c) + return err == nil +} + func (app *ReactAppWrapper) adminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !IsAdmin(c) { @@ -30,27 +55,22 @@ func (app *ReactAppWrapper) adminMiddleware() gin.HandlerFunc { func (app *ReactAppWrapper) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - token, err := c.Cookie(cookieName) - if err == http.ErrNoCookie { + claims, err := app.parseWebSessionCookie(c) + if errors.Is(err, http.ErrNoCookie) { log.Warn("missing cookie, trying headers") + var token string token, err = common.GetToken(c) + if err == nil { + claims = &WebUserClaims{} + if herr := common.ClaimsFromToken(claims, token, app.cfg.JWTSecretKey); herr != nil { + err = herr + } else if !slices.Contains(claims.Audience, WebUsage) { + err = errors.New("wrong token audience") + } + } } - - if err != nil { - log.Warn("[ui-authmiddleware] token parsing, ", err) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or incorrect token"}) - return - } - claims := &WebUserClaims{} - err = common.ClaimsFromToken(claims, token, app.cfg.JWTSecretKey) if err != nil { - log.Warn("[ui-authmiddleware] token verification, ", err) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or incorrect token"}) - return - } - - if !slices.Contains(claims.Audience, WebUsage) { - log.Warn("wrong token audience: ", claims.Audience) + log.Warn("[ui-authmiddleware] auth failed: ", err) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or incorrect token"}) return } diff --git a/internal/ui/oidc.go b/internal/ui/oidc.go new file mode 100644 index 00000000..0aac161f --- /dev/null +++ b/internal/ui/oidc.go @@ -0,0 +1,473 @@ +package ui + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "strings" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/ddvk/rmfakecloud/internal/model" + "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +const ( + oidcStateCookie = "oidc_state" + oidcNonceCookie = "oidc_nonce" + oidcVerifierCookie = "oidc_pkce_verifier" + oidcCookieMaxAge = 300 + // oidcSuccessPath is the frontend route the callback redirects to on success. + // The frontend must register a matching <Route path="/oidc-success"> to handle it. + oidcSuccessPath = "/oidc-success" +) + +var ( + errNoUserID = errors.New("no userid available: configured claim not found or empty") + errEmailNotVerified = errors.New("email not verified") +) + +type oidcUserIdentity struct { + Value string + ClaimName string +} + +func newOIDCUserIdentity(value, claimName string) oidcUserIdentity { + if claimName == "email" { + // Email identities must compare case-insensitively so repeated logins resolve to the same user. + value = strings.ToLower(value) + } + return oidcUserIdentity{Value: value, ClaimName: claimName} +} + +func (identity oidcUserIdentity) usesEmail() bool { + return identity.ClaimName == "email" +} + +// oidcClaims holds the standard OIDC claims read from the ID token. +// The configurable userid and admin claims may be arbitrary dotted paths; +// those still require the raw map passed to extractClaimPath. +type oidcClaims struct { + Nonce string `json:"nonce"` + Email string `json:"email"` + EmailVerified any `json:"email_verified"` // bool or string "true"/"false" depending on provider + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + PreferredUsername string `json:"preferred_username"` +} + +// randomURLSafeString generates a cryptographically random base64url-encoded string from n bytes. +func randomURLSafeString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// setOIDCCookie writes a short-lived SameSite=Lax cookie for OIDC flow state. +// Lax is required so the cookie survives the cross-site top-level redirect back from the IdP. +func (app *ReactAppWrapper) setOIDCCookie(c *gin.Context, name, value string) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(name, value, oidcCookieMaxAge, "/", "", app.cfg.HTTPSCookie, true) +} + +// clearOIDCCookie removes an OIDC flow cookie using the same attributes. +func (app *ReactAppWrapper) clearOIDCCookie(c *gin.Context, name string) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(name, "", -1, "/", "", app.cfg.HTTPSCookie, true) +} + +// extractClaimPath traverses a dotted path (e.g. "realm_access.roles") in a raw claims map. +func extractClaimPath(raw map[string]any, path string) (any, bool) { + parts := strings.SplitN(path, ".", 2) + val, ok := raw[parts[0]] + if !ok { + return nil, false + } + if len(parts) == 1 { + return val, true + } + nested, ok := val.(map[string]any) + if !ok { + return nil, false + } + return extractClaimPath(nested, parts[1]) +} + +// claimHasValue checks if a claim value (string, []any, or []string) contains expected. +func claimHasValue(value any, expected string) bool { + switch v := value.(type) { + case string: + return v == expected + case []any: + for _, item := range v { + if s, ok := item.(string); ok && s == expected { + return true + } + } + case []string: + for _, s := range v { + if s == expected { + return true + } + } + } + return false +} + +// claimIsTrue interprets an OIDC boolean claim that may arrive as a bool or as a +// string ("true"/"false"), as allowed by different providers. +func claimIsTrue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") + } + return false +} + +// oidcBegin starts the OIDC authorization code flow with PKCE, state, and nonce. +func (app *ReactAppWrapper) oidcBegin(c *gin.Context) { + state, err := randomURLSafeString(32) + if err != nil { + log.Error("[oidc] failed to generate state: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + nonce, err := randomURLSafeString(32) + if err != nil { + log.Error("[oidc] failed to generate nonce: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + pkceVerifier, err := randomURLSafeString(32) + if err != nil { + log.Error("[oidc] failed to generate PKCE verifier: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + app.setOIDCCookie(c, oidcStateCookie, state) + app.setOIDCCookie(c, oidcNonceCookie, nonce) + app.setOIDCCookie(c, oidcVerifierCookie, pkceVerifier) + + authURL := app.oauth2Config.AuthCodeURL( + state, + gooidc.Nonce(nonce), + oauth2.S256ChallengeOption(pkceVerifier), + ) + c.Redirect(http.StatusFound, authURL) +} + +// exchangeAndVerifyToken exchanges the authorization code for tokens and returns verified claims. +// Returns the raw claims map (for configurable dotted-path lookups), a typed oidcClaims +// struct (for standard fields), and true on success; false on error (caller already got an HTTP response). +func (app *ReactAppWrapper) exchangeAndVerifyToken(c *gin.Context, ctx context.Context, code, pkceVerifier string) (map[string]any, oidcClaims, bool) { + // Exchange authorization code for tokens, presenting the PKCE verifier + oauth2Token, err := app.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier)) + if err != nil { + log.Error("[oidc] token exchange failed: ", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token exchange failed"}) + return nil, oidcClaims{}, false + } + + // Extract raw ID token string + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing id_token in provider response"}) + return nil, oidcClaims{}, false + } + + // Verify ID token signature, expiry, issuer, and audience + idTokenVerifier := app.oidcProvider.Verifier(&gooidc.Config{ClientID: app.cfg.OIDC.ClientID}) + idToken, err := idTokenVerifier.Verify(ctx, rawIDToken) + if err != nil { + log.Warn("[oidc] ID token verification failed: ", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "ID token verification failed"}) + return nil, oidcClaims{}, false + } + + // Deserialize into typed struct for standard fields + var claims oidcClaims + if err := idToken.Claims(&claims); err != nil { + log.Error("[oidc] failed to extract typed claims: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return nil, oidcClaims{}, false + } + claims.Nonce = idToken.Nonce + + // Deserialize into raw map for configurable dotted-path claim lookups + var rawClaims map[string]any + if err := idToken.Claims(&rawClaims); err != nil { + log.Error("[oidc] failed to extract raw claims: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return nil, oidcClaims{}, false + } + + return rawClaims, claims, true +} + +// resolveOIDCIdentity extracts and validates the user identity from OIDC claims. +// It tries the configured claim first (using the raw map for dotted-path support), +// falls back to the typed email claim if not found, then enforces email verification +// when the identity is email-based. +// cfg.OIDCUserIDClaim is always non-empty (FromEnv applies the default). +func (app *ReactAppWrapper) resolveOIDCIdentity(rawClaims map[string]any, claims oidcClaims) (oidcUserIdentity, error) { + userIDClaimName := app.cfg.OIDC.UserIDClaim + + // Try to extract the configured claim (may be a dotted path like "realm_access.roles") + if claimVal, ok := extractClaimPath(rawClaims, userIDClaimName); ok { + if strVal, ok := claimVal.(string); ok { + if userIDValue := strings.TrimSpace(strVal); userIDValue != "" { + identity := newOIDCUserIdentity(userIDValue, userIDClaimName) + return identity, app.validateEmailIdentity(identity, claims) + } + } + } + + // If the primary claim does not yield a userid, fall back to the email claim. + if userIDClaimName != "email" { + if userIDValue := strings.TrimSpace(claims.Email); userIDValue != "" { + identity := newOIDCUserIdentity(userIDValue, "email") + return identity, app.validateEmailIdentity(identity, claims) + } + } + + return oidcUserIdentity{ClaimName: userIDClaimName}, errNoUserID +} + +// validateEmailIdentity enforces email verification for email-based identities. +// Without this, a provider that lets users set an arbitrary unverified email +// could be used to provision or take over an account for another address. +func (app *ReactAppWrapper) validateEmailIdentity(identity oidcUserIdentity, claims oidcClaims) error { + if identity.usesEmail() && !app.cfg.OIDC.AllowUnverifiedEmail && !claimIsTrue(claims.EmailVerified) { + return errEmailNotVerified + } + return nil +} + +// evaluateOIDCAdminStatus returns a *bool reflecting the result of the configured +// admin claim check. nil means the admin claim is not configured; a non-nil pointer +// holds the evaluated value. Callers must not touch a user's admin flag when nil. +func (app *ReactAppWrapper) evaluateOIDCAdminStatus(rawClaims map[string]any) *bool { + if app.cfg.OIDC.AdminClaim == "" || app.cfg.OIDC.AdminClaimValue == "" { + return nil + } + isAdmin := false + if claimVal, ok := extractClaimPath(rawClaims, app.cfg.OIDC.AdminClaim); ok { + isAdmin = claimHasValue(claimVal, app.cfg.OIDC.AdminClaimValue) + } + return &isAdmin +} + +// provisionNewUser creates and registers a new OIDC-provisioned user. +func (app *ReactAppWrapper) provisionNewUser(userIDValue string, claims oidcClaims, isAdmin bool) (*model.User, error) { + randomPassword, err := model.GenPassword() + if err != nil { + log.Error("[oidc] failed to generate password for provisioning: ", err) + return nil, err + } + + user, err := model.NewUser(userIDValue, randomPassword) + if err != nil { + log.Error("[oidc] failed to build user: ", err) + return nil, err + } + if email := strings.TrimSpace(claims.Email); email != "" { + user.Email = model.NormalizeUserID(email) + user.EmailVerified = claimIsTrue(claims.EmailVerified) + } + + // Populate user profile from OIDC claims + if claims.Name != "" { + user.Name = claims.Name + } + if claims.GivenName != "" { + user.GivenName = claims.GivenName + } + if claims.FamilyName != "" { + user.FamilyName = claims.FamilyName + } + if claims.PreferredUsername != "" { + user.Nickname = claims.PreferredUsername + } + + user.IsAdmin = isAdmin + + if err := app.userStorer.RegisterUser(user); err != nil { + log.Error("[oidc] failed to register provisioned user: ", err) + return nil, err + } + + return user, nil +} + +// getOrProvisionUser looks up or auto-provisions an OIDC user. +// adminStatus is nil when no admin claim is configured; in that case the existing +// user's admin flag is left untouched. A non-nil pointer carries the evaluated result. +func (app *ReactAppWrapper) getOrProvisionUser(userKey string, identity oidcUserIdentity, claims oidcClaims, adminStatus *bool) (*model.User, error) { + isAdmin := adminStatus != nil && *adminStatus + user, err := app.userStorer.GetUser(userKey) + if err != nil { + if !errors.Is(err, storage.ErrUserNotFound) { + log.Error("[oidc] storage error looking up user: ", err) + return nil, err + } + // User not found — provision new user + var newUser *model.User + newUser, err = app.provisionNewUser(identity.Value, claims, isAdmin) + if err != nil { + return nil, err + } + log.Info("[oidc] provisioned new user: ", userKey, " (claim=\"", identity.ClaimName, "\", value=\"", identity.Value, "\") admin=", isAdmin) + return newUser, nil + } + + // Existing user — only re-evaluate admin when an admin claim is configured, + // otherwise an OIDC login would silently strip admin from an existing user. + if adminStatus != nil && user.IsAdmin != *adminStatus { + user.IsAdmin = *adminStatus + if err := app.userStorer.UpdateUser(user); err != nil { + log.Error("[oidc] failed to update user admin status: ", err) + return nil, err + } + log.Info("[oidc] updated admin status for ", userKey, " to ", *adminStatus) + } + + return user, nil +} + +// completeOIDCLogin resolves the user identity from verified claims, gets or provisions +// the account, issues a session cookie, and redirects to the success page. +// It handles all identity/provisioning concerns after the protocol-level checks in oidcCallback. +func (app *ReactAppWrapper) completeOIDCLogin(c *gin.Context, rawClaims map[string]any, claims oidcClaims) { + // Extract, validate and normalise the user identity from claims + identity, err := app.resolveOIDCIdentity(rawClaims, claims) + if err != nil { + switch { + case errors.Is(err, errNoUserID): + log.Warn("[oidc] no userid available: configured claim '", identity.ClaimName, "' not found or empty") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "userid claim not found or empty"}) + case errors.Is(err, errEmailNotVerified): + log.Warn("[oidc] rejected login: email not verified for ", identity.Value) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "email not verified"}) + default: + log.Error("[oidc] resolveOIDCIdentity error: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + } + return + } + + // The stored user id is the sanitized userid; use the same key for lookup and + // provisioning so subsequent logins resolve to the same account. + userKey := model.NormalizeUserID(identity.Value) + + // Determine admin solely from the configured role claim; re-evaluated on every login + adminStatus := app.evaluateOIDCAdminStatus(rawClaims) + + // Get or provision user (lookup existing, or create new) + user, err := app.getOrProvisionUser(userKey, identity, claims, adminStatus) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + // Issue session and redirect to success page + if _, err := app.issueWebSession(c, user); err != nil { + log.Error("[oidc] failed to issue session: ", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + c.Redirect(http.StatusFound, oidcSuccessPath) +} + +// oidcCallback handles the redirect back from the OIDC provider. +// It validates the protocol-level cookies (state, nonce, PKCE), exchanges the +// authorization code, then delegates identity and provisioning to completeOIDCLogin. +func (app *ReactAppWrapper) oidcCallback(c *gin.Context) { + ctx := c.Request.Context() + + // Provider-side error — sanitize before returning to client + if errParam := c.Query("error"); errParam != "" { + log.Warn("[oidc] provider error: ", errParam, " — ", c.Query("error_description")) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication failed at identity provider"}) + return + } + + // Validate state cookie + stateCookie, err := c.Cookie(oidcStateCookie) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing state cookie"}) + return + } + if subtle.ConstantTimeCompare([]byte(stateCookie), []byte(c.Query("state"))) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "authentication failed"}) + return + } + + // Read nonce and PKCE verifier before clearing any cookies + nonceCookie, err := c.Cookie(oidcNonceCookie) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing nonce cookie"}) + return + } + pkceVerifier, err := c.Cookie(oidcVerifierCookie) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing pkce verifier cookie"}) + return + } + + // Clear all OIDC flow cookies before any external calls + app.clearOIDCCookie(c, oidcStateCookie) + app.clearOIDCCookie(c, oidcNonceCookie) + app.clearOIDCCookie(c, oidcVerifierCookie) + + // Exchange authorization code for tokens and verify claims + rawClaims, claims, ok := app.exchangeAndVerifyToken(c, ctx, c.Query("code"), pkceVerifier) + if !ok { + return // Error already written to response + } + + // Verify nonce with constant-time comparison + if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonceCookie)) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "authentication failed"}) + return + } + + app.completeOIDCLogin(c, rawClaims, claims) +} + +// meHandler returns the current authenticated user's profile as JSON. +// Used by the frontend after an OIDC redirect to hydrate localStorage. +func (app *ReactAppWrapper) meHandler(c *gin.Context) { + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil { + log.Error("[me] user not found: ", err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + scopes := "" + if user.Sync15 { + scopes = isSync15Key + } + roles := []string{"User"} + if user.IsAdmin { + roles = []string{AdminRole} + } + c.JSON(http.StatusOK, gin.H{ + "UserID": user.ID, + "Email": user.Email, + "Scopes": scopes, + "Roles": roles, + }) +} diff --git a/internal/ui/routes.go b/internal/ui/routes.go index a463f7f2..51ecd6c8 100644 --- a/internal/ui/routes.go +++ b/internal/ui/routes.go @@ -31,12 +31,27 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { return } - c.FileFromFS(indexReplacement, app) + // OIDC deployments: redirect unauthenticated users straight to the IdP. + // /oidc-success is excluded to avoid a redirect loop after callback. + if app.cfg.OIDC.Enabled() && + !strings.HasPrefix(uri, "/oidc-success") && + !app.webAuthenticated(c) { + c.Redirect(http.StatusFound, "/ui/api/oidc/login") + return + } + + app.serveIndex(c) }) r := router.Group("/ui/api") - r.POST("register", app.register) - r.POST("login", app.login) + if app.cfg.OIDC.Enabled() { + r.GET("oidc/login", app.oidcBegin) + r.GET("oidc/callback", app.oidcCallback) + } + if !app.cfg.OIDC.Enabled() { + r.POST("register", app.register) + r.POST("login", app.login) + } r.GET("logout", func(c *gin.Context) { c.SetCookie(cookieName, "/", -1, "", "", false, true) c.Status(http.StatusOK) @@ -47,6 +62,7 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { auth.HEAD("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + auth.GET("me", app.meHandler) auth.GET("sync", func(c *gin.Context) { uid := userID(c) br := c.GetString(browserIDContextKey) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6a7d6c98..7f313581 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,12 +1,14 @@ package ui import ( + "context" "io" "io/fs" "net/http" "path" "time" + gooidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ddvk/rmfakecloud/internal/app/hub" "github.com/ddvk/rmfakecloud/internal/app/passcodestore" "github.com/ddvk/rmfakecloud/internal/common" @@ -18,6 +20,8 @@ import ( "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" webui "github.com/ddvk/rmfakecloud/ui" "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" ) type backend interface { @@ -77,6 +81,8 @@ type ReactAppWrapper struct { backends map[common.SyncVersion]backend roomManager *screenshare.RoomManager mqtt mqttBridge + oidcProvider *gooidc.Provider + oauth2Config oauth2.Config } // hack for serving index.html on / @@ -121,6 +127,23 @@ func New(cfg *config.Config, roomManager: roomManager, mqtt: mqttBroker, } + + if cfg.OIDC.Enabled() { + provider, err := gooidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL) + if err != nil { + log.Fatalf("OIDC: failed to discover provider %s: %v", cfg.OIDC.ProviderURL, err) + } + staticWrapper.oidcProvider = provider + staticWrapper.oauth2Config = oauth2.Config{ + ClientID: cfg.OIDC.ClientID, + ClientSecret: cfg.OIDC.ClientSecret, + RedirectURL: cfg.OIDC.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: cfg.OIDC.Scopes(), + } + log.Info("OIDC provider initialized: ", cfg.OIDC.ProviderURL) + } + return &staticWrapper } @@ -136,6 +159,11 @@ func (w ReactAppWrapper) Open(filepath string) (http.File, error) { f, err := w.fs.Open(fullpath) return f, err } + +func (app *ReactAppWrapper) serveIndex(c *gin.Context) { + c.FileFromFS("/index.html", app.fs) +} + func badReq(c *gin.Context, message string) { c.AbortWithStatusJSON(http.StatusBadRequest, viewmodel.NewErrorResponse(message)) } diff --git a/mkdocs.yml b/mkdocs.yml index 78524a80..38312af5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,8 @@ nav: - With Docker: install/docker.md - With Helm: install/helm.md - Configuration: install/configuration.md + - OIDC: + - Authelia: install/oidc/authelia.md - Reverse Proxy: - Apache: install/reverse-proxy/apache.md - Nginx: install/reverse-proxy/nginx.md diff --git a/other/rmfakecloud.env b/other/rmfakecloud.env index 6aeff3f8..7cfe6de7 100644 --- a/other/rmfakecloud.env +++ b/other/rmfakecloud.env @@ -3,3 +3,19 @@ JWT_SECRET_KEY=tbd DATADIR=/var/rmfakecloud/ #RM_SMTP_USER= #RM_SMTP_ADDRESS= + +# OIDC authentication (all four required to enable; also set RM_HTTPS_COOKIE=true) +#OIDC_PROVIDER_URL=https://sso.example.com +#OIDC_CLIENT_ID=rmfakecloud +#OIDC_CLIENT_SECRET= +#OIDC_REDIRECT_URL=https://your-domain.com/ui/api/oidc/callback +#RM_HTTPS_COOKIE=true +# Optional (values shown are defaults): +#OIDC_USERID_CLAIM=preferred_username +#OIDC_DISABLE_LOCAL_LOGIN=false +#OIDC_ALLOW_UNVERIFIED_EMAIL=false +#OIDC_EXTRA_SCOPES= +#OIDC_DISPLAY_NAME=Login with OIDC +# Optional (no default; unset means feature is disabled): +#OIDC_ADMIN_CLAIM= +#OIDC_ADMIN_CLAIM_VALUE= diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 57e40ec2..64c3b489 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -10,6 +10,7 @@ import Navigationbar from "./components/Navigation"; import PasscodeResets from "./components/PasscodeResets"; import Login from "./pages/Login"; +import OidcCallback from "./pages/OidcCallback"; import Home from "./pages/Home"; import Connect from "./pages/Connect"; import Documents from "./pages/Documents"; @@ -55,6 +56,7 @@ export default function App() { <PrivateRoute path="/admin" roles={[Role.Admin]} component={Admin} /> <Route path="/login" component={Login} /> + <Route path="/oidc-success" component={OidcCallback} /> <Route component={NoMatch} /> </Switch> </div> diff --git a/ui/src/pages/OidcCallback/index.jsx b/ui/src/pages/OidcCallback/index.jsx new file mode 100644 index 00000000..6170fbff --- /dev/null +++ b/ui/src/pages/OidcCallback/index.jsx @@ -0,0 +1,44 @@ +import React, { useEffect } from "react"; +import { useHistory } from "react-router-dom"; + +import apiService from "../../services/api.service"; +import { useAuthState } from "../../common/useAuthContext"; + +const TIMEOUT_MS = 10000; + +const OidcCallback = () => { + const { dispatch } = useAuthState(); + const history = useHistory(); + + useEffect(() => { + dispatch({ type: "REQUEST_LOGIN" }); + + const timeout = setTimeout(() => { + dispatch({ type: "LOGIN_ERROR", error: "OIDC login timed out" }); + history.replace("/login"); + }, TIMEOUT_MS); + + apiService + .me() + .then((user) => { + clearTimeout(timeout); + dispatch({ type: "LOGIN_SUCCESS", payload: { user } }); + history.replace("/documents"); + }) + .catch(() => { + clearTimeout(timeout); + dispatch({ type: "LOGIN_ERROR", error: "OIDC login failed" }); + history.replace("/login"); + }); + + return () => clearTimeout(timeout); + }, [dispatch, history]); + + return ( + <div style={{ textAlign: "center", marginTop: "2rem" }}> + <span>Completing login…</span> + </div> + ); +}; + +export default OidcCallback; diff --git a/ui/src/services/api.service.js b/ui/src/services/api.service.js index f763066e..24bd8e5a 100644 --- a/ui/src/services/api.service.js +++ b/ui/src/services/api.service.js @@ -1,5 +1,4 @@ import constants from "../common/constants"; -import { jwtDecode } from "jwt-decode"; class ApiServices { header() { @@ -24,20 +23,33 @@ class ApiServices { if (!r.ok) { throw new Error(r.statusText); } - return r.text(); }) - .then((text) => { - let user = jwtDecode(text); - localStorage.setItem("currentUser", JSON.stringify(user)); - localStorage.setItem("authToken", text); - return user; - }); + .then(() => this.me()); } logout() { removeUser(); fetch(`${constants.ROOT_URL}/logout`); } + me() { + return fetch(`${constants.ROOT_URL}/me`, { + method: "GET", + headers: this.header(), + }).then((r) => { + if (r.status === 401) { + removeUser(); + throw new Error("Not authenticated"); + } + if (!r.ok) { + throw new Error(r.statusText); + } + return r.json(); + }).then((user) => { + localStorage.setItem("currentUser", JSON.stringify(user)); + return user; + }); + } + upload(parent, files) { const formData = new FormData(); formData.append("parent", parent); @@ -206,7 +218,6 @@ class ApiServices { function removeUser(){ localStorage.removeItem("currentUser"); - localStorage.removeItem("authToken"); } function handleError(r) { if (!r.ok) {