34
35:- module(oauth2,
36 [ oauth2_login/2, 37 oauth2_reply/2, 38 oauth2_validate_access_token/3, 39 40 oauth2_user_info/3, 41 oauth2_claim/2 42 ]). 43:- use_module(library(http/http_dispatch)). 44:- use_module(library(http/http_parameters)). 45:- use_module(library(http/http_open)). 46:- use_module(library(http/http_path)). 47:- use_module(library(http/http_host)). 48:- use_module(library(http/http_wrapper)). 49:- use_module(library(http/http_header)). 50:- use_module(library(http/html_write)). 51:- use_module(library(http/json)). 52:- use_module(library(base64)). 53:- use_module(library(utf8)). 54:- use_module(library(uri)). 55:- use_module(library(debug)). 56:- use_module(library(error)). 57:- use_module(library(option)). 58:- use_module(library(apply)). 59
79
80:- multifile
81 server_attribute/3, 82 login/3, 83 login_failed/2. 84
85:- multifile http:location/3. 86:- dynamic http:location/3. 87
88http:location(oauth2, root(oauth2), [priority(-100)]).
89
90:- http_handler(oauth2(.), oauth2, [prefix]). 91
146
154
155oauth2(Request) :-
156 option(path_info(Path), Request),
157 atomic_list_concat([ServerID,Action], /, Path), !,
158 oauth2(Action, ServerID, Request).
159oauth2(Request) :-
160 http_404([], Request).
161
162oauth2(_, ServerID, Request) :-
163 \+ server_attribute(ServerID, _, _), !,
164 http_404([], Request).
165oauth2(login, ServerID, Request) :- !,
166 oauth2_login(Request, [server(ServerID)]).
167oauth2(reply, ServerID, Request) :- !,
168 oauth2_reply(Request, [server(ServerID)]).
169oauth2(_, _, Request) :- !,
170 http_404([], Request).
171
172
178
179oauth2_login(Request, Options) :-
180 option(server(Server), Options),
181 oauth2_redirect_uri(Server, URI),
182 debug(oauth, 'Redirect to ~p', [URI]),
183 http_redirect(see_other, URI, Request).
184
185oauth2_redirect_uri(ServerID, URI) :-
186 server_attr(ServerID, url, ServerURI),
187 server_attr(ServerID, authorization_endpoint, Path),
188 server_attr(ServerID, redirect_uri, RedirectURI),
189 server_attr(ServerID, client_id, ClientID),
190 server_attr(ServerID, scope, Scope),
191
192 claims_attrs(ServerID, ClaimAttrs),
193 anti_forgery_state(AntiForgery),
194 get_time(Now),
195 asserta(forgery_state(AntiForgery, ServerID, RedirectURI, Now)),
196
197 uri_extend(ServerURI, Path,
198 [ response_type(code),
199 client_id(ClientID),
200 redirect_uri(RedirectURI),
201 scope(Scope),
202 state(AntiForgery)
203 | ClaimAttrs
204 ], URI).
205
206
207claims_attrs(ServerID, [claims=JSONString]) :-
208 server_attr(ServerID, claims, Dict), !,
209 with_output_to(string(JSONString),
210 json_write_dict(current_output, Dict)).
211claims_attrs(_, []).
212
219
220oauth2_reply(Request, Options) :-
221 option(server(ServerID), Options),
222 http_parameters(Request,
223 [ code(AuthCode, [string, optional(true)]),
224 state(State, [optional(true)]),
225 error_description(Error, [optional(true)])
226 ]),
227 ( nonvar(AuthCode),
228 nonvar(State)
229 -> debug(oauth, 'Code: ~p', [AuthCode]),
230 validate_forgery_state(State, _ServerID, _Redirect),
231 debug(oauth, 'State: OK', []),
232 oauth2_token_details(ServerID, AuthCode, TokenInfo),
233 call_login(Request, ServerID, TokenInfo)
234 ; nonvar(Error)
235 -> call_login_failed(Request, Error)
236 ; var(AuthCode)
237 -> existence_error(http_parameter, code)
238 ; existence_error(http_parameter, state)
239 ).
240
258
259call_login(Request, ServerID, TokenInfo) :-
260 login(Request, ServerID, TokenInfo),
261 !.
262call_login(_Request, ServerID, TokenInfo) :-
263 oauth2_user_info(ServerID, TokenInfo, UserInfo),
264 format('Content-type: text/plain~n~n'),
265 format('Oauth2 login using ~w succeeded~n', [ServerID]),
266 format('Token info: ~n'),
267 print_term(TokenInfo, [output(current_output)]),
268 format('~nUser info: ~n'),
269 print_term(UserInfo, [output(current_output)]).
270
271call_login_failed(Request, Error) :-
272 login_failed(Request, Error),
273 !.
274call_login_failed(_Request, Error) :-
275 reply_html_page(
276 title('Login failed'),
277 h1('Login failed'),
278 p(['ERROR: ', Error])).
279
280
285
286oauth2_validate_access_token(ServerID, AuthCode, Info) :-
287 server_attr(ServerID, url, ServerURI),
288 server_attr(ServerID, tokeninfo_endpoint, Path),
289 claims_attrs(ServerID, ClaimAttrs),
290
291 uri_extend(ServerURI, Path, ClaimAttrs, URI),
292 http_options(ServerID, Options),
293
294 setup_call_cleanup(
295 http_open(URI, In,
296 [ authorization(bearer(AuthCode)),
297 header(content_type, ContentType),
298 status_code(Code)
299 | Options
300 ]),
301 read_reply(Code, ContentType, In, Info),
302 close(In)).
303
309
310oauth2_user_info(ServerID, TokenInfo, UserInfo) :-
311 user_info(ServerID, TokenInfo.access_token, UserInfo).
312
313
317
318user_info(ServerID, AccessToken, Info) :-
319 server_attr(ServerID, url, ServerURI),
320 server_attr(ServerID, userinfo_endpoint, Path),
321 claims_attrs(ServerID, ClaimAttrs),
322
323 uri_extend(ServerURI, Path, ClaimAttrs, URI),
324 http_options(ServerID, Options),
325 debug(oauth, 'Request user info using ~q', [URI]),
326
327 setup_call_cleanup(
328 http_open(URI, In,
329 [ authorization(bearer(AccessToken)),
330 header(content_type, ContentType),
331 status_code(Code)
332 | Options
333 ]),
334 read_reply(Code, ContentType, In, Info),
335 close(In)).
336
341
342oauth2_token_details(ServerID, AuthCode, Dict) :-
343 server_attr(ServerID, url, ServerURI),
344 server_attr(ServerID, token_endpoint, Path),
345 server_attr(ServerID, redirect_uri, RedirectURI),
346 server_attr(ServerID, client_id, ClientID),
347 server_attr(ServerID, client_secret, ClientSecret),
348 server_attr(ServerID, scope, Scope),
349
350 uri_extend(ServerURI, Path, [], URI),
351 http_options(ServerID, Options),
352
353 setup_call_cleanup(
354 http_open(URI, In,
355 [ authorization(basic(ClientID, ClientSecret)),
356 post(form([ grant_type(authorization_code),
357 scope(Scope),
358 code(AuthCode),
359 redirect_uri(RedirectURI),
360 client_id(ClientID),
361 client_secret(ClientSecret)
362 ])),
363 request_header('Accept'='application/json;q=1.0,\c
364 */*;q=0.1'),
365 header(content_type, ContentType),
366 status_code(Code)
367 | Options
368 ]),
369 read_reply(Code, ContentType, In, Dict),
370 close(In)).
371
372read_reply(Code, ContentType, In, Dict) :-
373 debug(oauth, 'Token details returned ~p ~p', [Code, ContentType]),
374 http_parse_header_value(content_type, ContentType, Parsed),
375 read_reply2(Code, Parsed, In, Dict).
376
382
383read_reply2(200, media(application/json, _Attributes), In, Dict) :- !,
384 json_read_dict(In, Dict, [default_tag(oauth2)]).
385read_reply2(200, media(text/plain, _Attributes), In, Dict) :- !,
386 read_string(In, _, Reply),
387 uri_query_components(Reply, Fields0),
388 maplist(convert_field, Fields0, Fields),
389 dict_create(Dict, oauth2, Fields).
390read_reply2(Code, media(application/json, _Attributes), In,
391 error{code:Code, details:Details}) :- !,
392 json_read_dict(In, Details, [default_tag(error)]).
393read_reply2(Code, Type, In,
394 error{code:Code, message:Reply}) :-
395 debug(oauth(token), 'Got code ~w, type ~q', [Code, Type]),
396 read_string(In, _, Reply).
397
398convert_field(expires=Atom, expires=Number) :-
399 atom_number(Atom, Number), !.
400convert_field(Field, Field).
401
402
406
407server_attr(ServerID, Attr, Value) :-
408 ( server_attribute(ServerID, Attr, Value0)
409 -> Value = Value0
410 ; debug(oauth, 'No endpoint for ~q; trying defaults', [Attr]),
411 default_attribute(Attr, ServerID, Value0)
412 -> Value = Value0
413 ; optional_attr(Attr)
414 -> fail
415 ; existence_error(oauth2_server_attribute, Attr)
416 ).
417
421
422default_attribute(redirect_uri, ServerID, URI) :- !,
423 http_current_request(Request),
424 http_public_host_url(Request, HostURL),
425 http_absolute_location(oauth2(ServerID/reply), Path, []),
426 atom_concat(HostURL, Path, URI).
427default_attribute(discovery_endpoint, ServerID, URI) :- !,
428 server_attr(ServerID, url, Base),
429 uri_extend(Base, '/.well-known/openid-configuration', [], URI).
430default_attribute(cert_verify_hook, _, Hook) :- !,
431 Hook = default.
432default_attribute(url, _, _) :- !,
433 fail.
434default_attribute(Attribute, ServerID, URI) :-
435 oauth2_discover(ServerID, Dict),
436 URI = Dict.get(Attribute).
437
441
442optional_attr(claims).
443
444
449
450http_options(ServerID, Options) :-
451 server_attr(ServerID, cert_verify_hook, Hook),
452 Hook \== default, !,
453 Options = [ cert_verify_hook(Hook) ].
454http_options(_, []).
455
456
457 460
461:- dynamic forgery_state/4. 462
463validate_forgery_state(State, Site, Redirect) :-
464 ( forgery_state(State, Site, Redirect, Stamp)
465 -> retractall(forgery_state(State, Site, Redirect, Stamp))
466 ; throw(http_reply(not_acceptable('Invalid state parameter')))
467 ).
468
469anti_forgery_state(State) :-
470 Rand is random(1<<100),
471 variant_sha1(Rand, State).
472
473
474 477
481
482:- dynamic
483 discovered_data/3. 484
485oauth2_discover(ServerID, Dict) :-
486 ( discovered_data(ServerID, Dict0)
487 -> Dict = Dict0
488 ; discover_data(ServerID, Expires, Dict0),
489 cache_data(ServerID, Expires, Dict0),
490 Dict = Dict0
491 ).
492
493discover_data(ServerID, Expires, Dict) :-
494 server_attr(ServerID, discovery_endpoint, DiscoverURL),
495 http_options(ServerID, Options),
496
497 http_open(DiscoverURL, In,
498 [ header(expires, Expires),
499 status_code(Status)
500 | Options
501 ]),
502 ( Status == 200
503 -> json_read_dict(In, Dict)
504 ; debug(oauth, 'Got status ~p from discovery endpoint; ignoring',
505 [Status]),
506 Dict = _{},
507 setup_call_cleanup(
508 open_null_stream(Out),
509 copy_stream_data(In, Out),
510 close(Out))
511 ),
512 close(In).
513
514discovered_data(URL, Data) :-
515 discovered_data(URL, Expires, Data0),
516 get_time(Now),
517 ( Now =< Expires
518 -> Data = Data0
519 ; retractall(discovered_data(URL, Expires, _)),
520 fail
521 ).
522
523cache_data(URL, Expires, Data) :-
524 atomic(Expires),
525 parse_time(Expires, _Format, Stamp), !,
526 asserta(discovered_data(URL, Stamp, Data)).
527cache_data(_, _, _).
528
529
530 533
537
538uri_extend(Base, Relative, Query, URI) :-
539 uri_resolve(Relative, Base, URI0),
540 uri_extend_query(URI0, Query, URI).
541
546
547uri_extend_query(URI0, Query, URI) :-
548 uri_components(URI0, Components0),
549 extend_query(Components0, Query, Query1),
550 uri_data(search, Components0, Query1, Components1),
551 uri_components(URI, Components1).
552
553extend_query(Components, QueryEx, Query) :-
554 uri_data(search, Components, Query0),
555 ( var(Query0)
556 -> uri_query_components(Query, QueryEx)
557 ; uri_query_components(Query0, Q0),
558 merge_components(Q0, QueryEx, Q),
559 uri_query_components(Query, Q)
560 ).
561
562merge_components([], Q, Q).
563merge_components([N=_|T0], Q1, Q) :-
564 memberchk(N=_, Q1), !,
565 merge_components(T0, Q1, Q).
566merge_components([H|T0], Q1, [H|Q]) :-
567 merge_components(T0, Q1, Q).
568
569
570 573
577
578oauth2_claim(TokenInfo, Claim) :-
579 jwt(TokenInfo.get(id_token), Claim).
580
581
587
588jwt(String, Object) :-
589 nonvar(String),
590 split_string(String, ".", "", [Header64,Object64|_Parts]),
591 base64url_json(Header64, _Header),
592 base64url_json(Object64, Object).
593
598
599base64url_json(String, JSON) :-
600 string_codes(String, Codes),
601 phrase(base64url(Bytes), Codes),
602 phrase(utf8_codes(Text), Bytes),
603 setup_call_cleanup(
604 open_string(Text, Stream),
605 json_read_dict(Stream, JSON),
606 close(Stream))