Skip to content

Commit 8a0ae64

Browse files
committed
Freeform extra claims, update-user command, and lots of tweaks
Also fixes an issue with the login form, and generally makes sure error/success pages look ok
1 parent 59a69f0 commit 8a0ae64

16 files changed

Lines changed: 217 additions & 100 deletions

File tree

resources/oak/config.edn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{:application/name "Oak"
22
:http/host "0.0.0.0"
33
:http/port 4800
4+
;; not set by default, in which case it gets inferred from the request
5+
;; :http/origin "http://localhost:4800"
46
:password/hash-type "argon2" ;; "bcrypt", "scrypt"
57
:http-session/cookie-name "oak-session"
68
:redis/host "localhost"

src/co/gaiwan/oak/apis/auth.clj

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[co.gaiwan.oak.html.email.password-reset :as email-views]
99
[co.gaiwan.oak.html.layout :as layout]
1010
[co.gaiwan.oak.html.login-page :as login-form]
11-
[co.gaiwan.oak.html.password-reset :as views]
11+
[co.gaiwan.oak.html.auth :as views]
1212
[co.gaiwan.oak.lib.auth-middleware :as auth-mw]
1313
[co.gaiwan.oak.lib.db :as db]
1414
[co.gaiwan.oak.lib.email :as email]
@@ -20,18 +20,21 @@
2020
(java.time Instant)
2121
(java.time.temporal ChronoUnit)))
2222

23-
(defn GET-login [req]
23+
(defn GET-login
24+
{:parameters
25+
{:query [:map [:id {:optional true} string?]]}}
26+
[req]
2427
(if (:identity req)
2528
{:status 302
2629
:headers {"Location" (routing/url-for req :home/dash)}}
2730
{:status 200
28-
:html/body [login-form/login-html req]
31+
:html/body [login-form/login-html req {:identifier (-> req :parameters :query :id)}]
2932
:html/head [:title "Oak Login"]}))
3033

3134
(defn POST-login
3235
{:parameters
3336
{:form
34-
{:email string?
37+
{:identifier string?
3538
:password string?}}}
3639
[{:keys [db parameters session] :as req}]
3740
(if-let [id (identity/validate-login db (:form parameters))]
@@ -41,11 +44,12 @@
4144
:session {:identity id
4245
:auth-time (System/currentTimeMillis)}}
4346
{:status 200
44-
:html/body [:p "Successfully authenticated"]
47+
:html/body [views/success-page req {:title "Successfully authenticated"}]
4548
:session {:identity id
4649
:auth-time (System/currentTimeMillis)}})
4750
{:status 403
48-
:html/body [:p "Invalid credentials"]}))
51+
:html/body [login-form/login-html req {:identifier (:identifier (:form parameters))
52+
:error "Invalid username or password"}]}))
4953

5054
(defn GET-logout [req]
5155
{:status 302

src/co/gaiwan/oak/apis/oauth.clj

Lines changed: 17 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,28 @@
22
"OAuth 2.1 authorization and token exchange"
33
(:require
44
[clojure.string :as str]
5+
[co.gaiwan.oak.domain.identity :as identity]
56
[co.gaiwan.oak.domain.jwk :as jwk]
67
[co.gaiwan.oak.domain.jwt :as jwt]
78
[co.gaiwan.oak.domain.oauth-authorization :as oauth-authorization]
89
[co.gaiwan.oak.domain.oauth-client :as oauth-client]
910
[co.gaiwan.oak.domain.oauth-code :as oauth-code]
1011
[co.gaiwan.oak.domain.refresh-token :as refresh-token]
1112
[co.gaiwan.oak.domain.scope :as scope]
13+
[co.gaiwan.oak.html.layout :as layout]
14+
[co.gaiwan.oak.html.oauth :as views]
1215
[co.gaiwan.oak.lib.auth-middleware :as auth-mw]
13-
[co.gaiwan.oak.lib.debug-middleware :as debug]
14-
[co.gaiwan.oak.html.forms :as f]
1516
[co.gaiwan.oak.util.hash :as hash]
1617
[co.gaiwan.oak.util.jose :as jose]
1718
[co.gaiwan.oak.util.log :as log]
1819
[co.gaiwan.oak.util.routing :as routing]
1920
[lambdaisland.hiccup.middleware :as hiccup-mw]
2021
[lambdaisland.uri :as uri]))
2122

22-
(defn auth-error-html [extra-info]
23-
[:<>
24-
[:h1 "Oh no! Something went wrong."]
25-
[:hr]
26-
[:p "It looks like the link you clicked to sign in isn't quite right. Don't worry, this is usually a simple fix."]
27-
[:p "The application you're trying to use did not pass along all information needed to let you log in. This could be because of a broken link or a typo."]
28-
[:p "What you can do:"
29-
[:ul
30-
[:li "Head back to the application and try clicking the sign-in button again."]
31-
[:li "If the issue continues, please reach out to the application's support team. They'll know exactly what to do!"]]]
32-
[:p "We apologize for the inconvenience!"]
33-
[:hr]
34-
[:details
35-
[:summary "Technical information"]
36-
extra-info]])
37-
38-
(defn permission-dialog-html [oauth-client requested-scopes params]
39-
(let [{:keys [client_id redirect_uri response_type scope state code_challenge code_challenge_method]} params]
40-
[:<>
41-
[:p (:oauth-client/client-name oauth-client) " wants to access your account."]
42-
[:p "This will allow " (:oauth-client/client-name oauth-client) " to:"]
43-
[:ul
44-
(for [s requested-scopes]
45-
[:li (scope/desc s)])]
46-
[f/form {:method "post"}
47-
[:input {:type "hidden", :name "client_id", :value client_id}]
48-
[:input {:type "hidden", :name "redirect_uri", :value redirect_uri}]
49-
[:input {:type "hidden", :name "response_type", :value response_type}]
50-
[:input {:type "hidden", :name "scope", :value scope}]
51-
[:input {:type "hidden", :name "code_challenge", :value code_challenge}]
52-
[:input {:type "hidden", :name "code_challenge_method", :value code_challenge_method}]
53-
(when state [:input {:type "hidden", :name "state", :value state}])
54-
[:button {:type "submit", :name "allow", :value "true"} "Allow"]
55-
[:button {:type "submit", :name "allow", :value "false"} "Cancel"]]]))
56-
5723
(defn error-html-response [message]
5824
{:status 400
5925
:html/head [:title "Something went wrong"]
60-
:html/body [auth-error-html message]})
26+
:html/body [views/auth-error-html message]})
6127

6228
(defn error-redirect-response [redirect-uri kvs]
6329
{:status 302
@@ -124,7 +90,7 @@
12490
"Ask the user permission for the requested scopes"
12591
[oauth-client scope params]
12692
{:status 200
127-
:html/body [permission-dialog-html oauth-client (str/split scope #"\s+") params]})
93+
:html/body [views/permission-dialog-html oauth-client (str/split scope #"\s+") params]})
12894

12995
(defn authorize-response
13096
"Happy path, store a code for later token exchange, and send the user back to
@@ -243,6 +209,7 @@
243209
;; Look up the authorization code
244210
(if-let [code-entity (oauth-code/find-one db {:code code :client-uuid client-uuid})]
245211
(let [{:oauth-code/keys [identity-id scope code-challenge code-challenge-method]} code-entity
212+
identity (identity/find-one db {:id identity-id})
246213
code-verifier-valid? (when (= code-challenge-method "S256")
247214
(and code_verifier
248215
(= code-challenge (hash/sha256-base64url code_verifier))))]
@@ -260,11 +227,15 @@
260227
at-claims (jwt/access-token-claims claim-opts)
261228
access-token (jose/build-jwt jwk at-claims)
262229
openid? (scope/subset? #{"openid"} scope)
263-
id-claims (when openid? (jwt/id-token-claims db (assoc claim-opts :access-token access-token)))
230+
offline-access? (scope/subset? #{"offline_access"} scope)
231+
id-claims (when openid? (merge (jwt/id-token-claims db (assoc claim-opts :access-token access-token))
232+
(:identity/claims identity)
233+
{"email" "foo@bar.com"}))
264234
id-token (when openid? (jose/build-jwt jwk id-claims))
265-
refresh-token (refresh-token/create! db {:client-id client-uuid
266-
:access-token-claims at-claims
267-
:id-token-claims id-claims})]
235+
refresh-token (when offline-access?
236+
(refresh-token/create! db {:client-id client-uuid
237+
:access-token-claims at-claims
238+
:id-token-claims id-claims}))]
268239
(log/info :token/exchange {:scope scope :id-claims id-claims :at-claims at-claims})
269240
;; Delete the used code
270241
(oauth-code/delete! db {:code code :client-uuid client-uuid})
@@ -275,8 +246,9 @@
275246
(cond-> {:access_token access-token
276247
:token_type "Bearer"
277248
:expires_in (- (get at-claims "exp") (get at-claims "iat"))
278-
:refresh_token refresh-token
279249
:scope scope}
250+
offline-access?
251+
(assoc :refresh_token refresh-token)
280252
id-token
281253
(assoc :id_token id-token))})
282254
(error-response "server_error" "No default JWK configured")))
@@ -476,6 +448,7 @@
476448
:get #'GET-authorization-server-metadata}]
477449
["/oauth" {:openapi {:tags ["oauth"]}}
478450
["/authorize" {:name :oauth/authorize
451+
:html/layout layout/layout
479452
:middleware [hiccup-mw/wrap-render]
480453
:get #'GET-authorize
481454
:post #'POST-authorize}]

src/co/gaiwan/oak/app/admin_cli.clj

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
(:require
88
[clojure.string :as str]
99
[co.gaiwan.oak.app.config :as config]
10+
[co.gaiwan.oak.domain.credential :as credential]
11+
[co.gaiwan.oak.domain.identifier :as identifier]
1012
[co.gaiwan.oak.domain.identity :as identity]
1113
[co.gaiwan.oak.domain.jwk :as jwk]
1214
[co.gaiwan.oak.domain.jwt :as jwt]
1315
[co.gaiwan.oak.domain.oauth-client :as oauth-client]
1416
[co.gaiwan.oak.lib.cli-error-mw :as cli-error-mw]
17+
[co.gaiwan.oak.lib.db :as db]
1518
[lambdaisland.cli :as cli])
1619
(:import
1720
(java.io StringWriter)))
@@ -84,12 +87,43 @@
8487
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
8588

8689
(defn create-user
87-
"Create a new user user"
88-
{:flags ["--email <email>" "Email address"
89-
"--password <password>" "User password"]}
90+
"Create a new user"
91+
{:flags ["--email <email>" {:doc "Email address"
92+
:required true}
93+
"--password <password>" "User password"
94+
"--claim <k=v>" {:doc "Additional claim"
95+
:handler (fn [opts claim]
96+
(let [[k v] (str/split claim #"=")]
97+
(update opts :claims assoc k v)))}]}
9098
[opts]
9199
{:data [(identity/create-user! (db) opts)]})
92100

101+
(defn update-user
102+
"Update a user/identity"
103+
{:flags ["--email <email>" {:doc "Email address"
104+
:required true}
105+
"--password <password>" "User password"
106+
"--claim <k=v>" {:doc "Set a JWK claim, e.g. `--claim 'groups=admin owner'`
107+
108+
Leave the part after `=` blank to unset a claim."
109+
:handler (fn [opts claim]
110+
(let [[k v] (str/split claim #"=")]
111+
(update opts :claims assoc k v)))}]}
112+
[{:keys [email password claims] :as opts}]
113+
(db/with-transaction [conn (db)]
114+
(if-let [{user-id :identifier/identity-id} (identifier/find-one conn {:type "email" :value email})]
115+
(do
116+
(when claims
117+
(let [{orig-claims :identity/claims} (identity/find-one conn {:id user-id})]
118+
(identity/update! conn {:id user-id :claims (reduce (fn [c [k v]]
119+
(if (str/blank? v)
120+
(dissoc c k)
121+
(assoc c k v)))
122+
orig-claims
123+
claims)})))
124+
(when password
125+
(credential/set-password! conn {:identity-id user-id :password password}))))))
126+
93127
(defn list-users
94128
"List users"
95129
[opts]
@@ -107,6 +141,7 @@
107141
:commands
108142
["create" #'create-user
109143
"list" #'list-users
144+
"update" #'update-user
110145
"delete <id>" #'delete-user]})
111146

112147
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

src/co/gaiwan/oak/domain/credential.clj

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,4 @@
118118
:type "totp_secret"
119119
:value secret})
120120

121-
(get-hash (user/db) tmp-uuid "totp")
122-
(get-hash (user/db) tmp-uuid "password")
123-
(get-password-hash (user/db) tmp-uuid)
124121
)

src/co/gaiwan/oak/domain/identity.clj

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111

1212
(def attributes
1313
[[:id :uuid :primary-key]
14-
[:type :text [:not nil]]])
14+
[:type :text [:not nil]]
15+
;; freeform extra jwt claims, escape hatch
16+
[:claims :jsonb]])
1517

16-
(defn create! [db {:keys [id type]}]
18+
(defn create! [db {:keys [id type claims]}]
1719
(db/insert! db :identity {:id (or id (uuid/v7))
18-
:type (or type "user")}))
20+
:type (or type "user")
21+
:claims claims}))
1922

20-
(defn create-user! [db {:keys [email password]}]
23+
(defn create-user! [db {:keys [email password claims]}]
2124
(db/with-transaction [conn db]
22-
(let [ident (create! conn {:type "user"})
25+
(let [ident (create! conn {:type "user" :claims claims})
2326
id (:identity/id ident)]
2427
(identifier/create!
2528
conn
@@ -33,6 +36,15 @@
3336
:password password})
3437
ident)))
3538

39+
(defn update! [db {:keys [id type claims]}]
40+
(db/execute-honey!
41+
db
42+
{:update :identity
43+
:set (cond-> {}
44+
type (assoc :type type)
45+
claims (assoc :claims [:lift claims]))
46+
:where [:= :id id]}))
47+
3648
(defn list-all [db]
3749
(doall
3850
(for [i (db/execute-honey! db {:select [:*] :from :identity})]
@@ -46,7 +58,7 @@
4658

4759
(defn validate-login
4860
"Return identity id if password matches, nil otherwise"
49-
[db {:keys [identifier password]}]
61+
[db {:keys [identifier password] :as opts}]
5062
(when-let [identity
5163
(some->
5264
(db/execute-honey!

src/co/gaiwan/oak/domain/oauth_authorization.clj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,12 @@
4545
db
4646
{:delete-from :oauth-authorization
4747
:where [:= identity-id :identity_id]}))
48+
49+
(comment
50+
(db/execute-honey!
51+
(user/db)
52+
{:select [:*]
53+
:from :oauth-authorization
54+
})
55+
56+
)

src/co/gaiwan/oak/domain/oauth_client.clj

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,7 @@
112112
id
113113
(conj [:= :oauth_client.id id])
114114
client-id
115-
(conj [:= :oauth_client.client-id client-id])
116-
))
115+
(conj [:= :oauth_client.client-id client-id])))
117116

118117
(defn find-by-client-id [db client-id]
119118
(first
@@ -131,4 +130,12 @@
131130

132131
(comment
133132
(create! (user/db) {:client-name "My client"
134-
:redirect-uris []}))
133+
:redirect-uris []})
134+
135+
(db/execute-honey! (user/db)
136+
{:update :oauth_client
137+
:set {:redirect-uris [:lift ["http://localhost:3111/user/oauth2/oak/callback"]]
138+
:scope "openid profile email"}
139+
:where [:= :client_id "2ZyVA5TnoXw.oak.client"]})
140+
141+
)
Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
(ns co.gaiwan.oak.html.password-reset
1+
(ns co.gaiwan.oak.html.auth
22
(:require
33
[co.gaiwan.oak.html.forms :as f]
44
[co.gaiwan.oak.html.graphics :as g]
@@ -19,7 +19,7 @@
1919

2020
(defn password-reset-html [req]
2121
[layout
22-
[:h1 "Reset Password"]
22+
[:h1 "Reset password"]
2323
[:p "Enter your email and we'll send you a link to reset your password."]
2424
[f/form {:method "POST"}
2525
[f/input-group
@@ -56,7 +56,7 @@
5656
([req {:keys [email password confirm-password minlength
5757
password-error confirm-error]}]
5858
[layout
59-
[:h1 "Reset Password"]
59+
[:h1 "Set your new password"]
6060
[:p "Choose a new password for " [:strong email]]
6161
[f/form {:method "POST"}
6262
[f/input-group
@@ -85,18 +85,23 @@
8585
:minlength minlength}]
8686
[f/submit {:type "submit" :value "Reset password"}]]]))
8787

88-
(o/defstyled password-reset-success :div
88+
(o/defstyled success-page :div
8989
{:text-align :center}
9090
[g/checkmark {:height "3rem"
9191
:margin "1rem"
9292
:align-self :center}]
93-
([req]
93+
([req {:keys [title]} & children]
9494
[layout
95-
[:h1 "Password Successfully Reset"]
96-
[:p "Your password has been updated. You can now log in with your new password."]
95+
[:h1 title]
96+
(into [:<>] children)
97+
9798
[g/checkmark]
9899
[:a.subtle {:href (routing/url-for req :auth/login)} "Back to Login"]]))
99100

101+
(defn password-reset-success [req]
102+
[success-page req
103+
{:title "Password Successfully Reset"}
104+
[:p "Your password has been updated. You can now log in with your new password."]])
100105

101106
(o/defstyled error-page :div
102107
{:text-align :center}

0 commit comments

Comments
 (0)