Current section

Files

Jump to
erlang_adk src auth oidc adk_oidcc_authorization_code_adapter.erl
Raw

src/auth/oidc/adk_oidcc_authorization_code_adapter.erl

%% @doc Oidcc 3.7 authorization-code adapter with mandatory S256 PKCE.
%%
%% oidcc:retrieve_token/5 validates issuer, signature, audience, time claims,
%% authorization-party and the bound nonce before it returns. This adapter
%% extracts the validated provider subject and retains only the refresh
%% credential needed by the existing token manager. The local principal is a
%% separate issuer-bound identity and remains the credential-store scope; it
%% must not be compared to the provider's raw sub claim.
-module(adk_oidcc_authorization_code_adapter).
-behaviour(adk_authorization_code_adapter).
-include_lib("oidcc/include/oidcc_token.hrl").
-export([validate_context/1, authorization_uri/2, exchange_code/3]).
-ifdef(TEST).
-export([test_validated_refresh_credential/3]).
-endif.
-define(MAX_CLIENT_ID_BYTES, 4096).
-define(MAX_CLIENT_SECRET_BYTES, 16384).
-define(MAX_REFRESH_TOKEN_BYTES, 65536).
-define(MAX_ACCESS_TOKEN_BYTES, 131072).
-define(MAX_ID_TOKEN_BYTES, 131072).
-define(MAX_SUBJECT_BYTES, 4096).
-define(MAX_TOKEN_TERM_BYTES, 1048576).
-spec validate_context(map()) -> ok | {error, invalid_adapter_context}.
validate_context(#{provider_worker := Provider,
client_id := ClientId,
client_secret := ClientSecret} = Context) ->
case exact_keys(Context, [provider_worker, client_id, client_secret])
andalso valid_provider(Provider)
andalso valid_text(ClientId, ?MAX_CLIENT_ID_BYTES)
andalso valid_text(ClientSecret, ?MAX_CLIENT_SECRET_BYTES) of
true -> ok;
false -> {error, invalid_adapter_context}
end;
validate_context(_Context) ->
{error, invalid_adapter_context}.
-spec authorization_uri(map(),
adk_authorization_code_adapter:authorization_opts()) ->
{ok, binary()} | {error, authorization_unavailable}.
authorization_uri(#{provider_worker := Provider,
client_id := ClientId,
client_secret := ClientSecret}, Opts) ->
OidccOpts0 = #{state => maps:get(state, Opts),
nonce => maps:get(nonce, Opts),
pkce_verifier => maps:get(pkce_verifier, Opts),
require_pkce => true,
redirect_uri => maps:get(redirect_uri, Opts),
scopes => maps:get(scopes, Opts)},
OidccOpts = resource_extension(url_extension, Opts, OidccOpts0),
try oidcc:create_redirect_url(
Provider, ClientId, ClientSecret, OidccOpts) of
{ok, Uri0} ->
case safe_binary(Uri0) of
{ok, Uri} -> {ok, Uri};
error -> {error, authorization_unavailable}
end;
{error, _Reason} ->
{error, authorization_unavailable}
catch
_:_ -> {error, authorization_unavailable}
end;
authorization_uri(_Context, _Opts) ->
{error, authorization_unavailable}.
-spec exchange_code(map(), binary(),
adk_authorization_code_adapter:exchange_opts()) ->
{ok, adk_credential_store:credential()} |
{error, authorization_failed}.
exchange_code(#{provider_worker := Provider,
client_id := ClientId,
client_secret := ClientSecret}, Code, Opts)
when is_binary(Code), byte_size(Code) > 0 ->
OidccOpts0 = #{nonce => maps:get(nonce, Opts),
pkce_verifier => maps:get(pkce_verifier, Opts),
require_pkce => true,
redirect_uri => maps:get(redirect_uri, Opts),
scope => maps:get(scopes, Opts)},
OidccOpts = resource_extension(body_extension, Opts, OidccOpts0),
try oidcc:retrieve_token(
Code, Provider, ClientId, ClientSecret, OidccOpts) of
{ok, Token} ->
validated_refresh_credential(Token, ClientId, ClientSecret);
{error, _Reason} ->
{error, authorization_failed}
catch
_:_ -> {error, authorization_failed}
end;
exchange_code(_Context, _Code, _Opts) ->
{error, authorization_failed}.
validated_refresh_credential(
Token = #oidcc_token{id = #oidcc_token_id{token = IdToken,
claims = Claims},
access = #oidcc_token_access{token = AccessToken,
type = TokenType},
refresh = #oidcc_token_refresh{token = RefreshToken}},
ClientId, ClientSecret)
when is_map(Claims),
is_binary(IdToken), byte_size(IdToken) > 0,
byte_size(IdToken) =< ?MAX_ID_TOKEN_BYTES,
is_binary(AccessToken), byte_size(AccessToken) > 0,
byte_size(AccessToken) =< ?MAX_ACCESS_TOKEN_BYTES,
is_binary(TokenType), byte_size(TokenType) > 0,
byte_size(TokenType) =< 128,
is_binary(RefreshToken), byte_size(RefreshToken) > 0,
byte_size(RefreshToken) =< ?MAX_REFRESH_TOKEN_BYTES ->
%% Claims are trusted only because oidcc:retrieve_token/5 already
%% performed ID-token validation with the exact nonce passed above.
case {maps:get(<<"sub">>, Claims, undefined),
safe_external_size(Token)} of
{Subject, Size}
when is_binary(Subject), byte_size(Subject) > 0,
byte_size(Subject) =< ?MAX_SUBJECT_BYTES,
is_integer(Size), Size =< ?MAX_TOKEN_TERM_BYTES ->
{ok, #{kind => oauth_refresh_token,
client_id => ClientId,
client_secret => ClientSecret,
refresh_token => RefreshToken,
expected_subject => Subject}};
_ ->
{error, authorization_failed}
end;
validated_refresh_credential(_Token, _ClientId, _ClientSecret) ->
{error, authorization_failed}.
resource_extension(Key, #{resource := Resource}, Opts)
when is_binary(Resource), byte_size(Resource) > 0 ->
Opts#{Key => [{<<"resource">>, Resource}]};
resource_extension(_Key, _FlowOpts, Opts) ->
Opts.
valid_provider(Provider) when is_pid(Provider) -> true;
valid_provider(Provider) when is_atom(Provider) -> Provider =/= undefined;
valid_provider(_) -> false.
valid_text(Value, Max) when is_binary(Value) ->
byte_size(Value) > 0 andalso byte_size(Value) =< Max;
valid_text(_, _) -> false.
exact_keys(Map, Allowed) ->
lists:sort(maps:keys(Map)) =:= lists:sort(Allowed).
safe_binary(Value) ->
try erlang:iolist_size(Value) of
Size when Size > 0, Size =< 8192 -> {ok, iolist_to_binary(Value)};
_ -> error
catch
_:_ -> error
end.
safe_external_size(Term) ->
try erlang:external_size(Term) catch _:_ -> invalid end.
-ifdef(TEST).
test_validated_refresh_credential(Token, ClientId, ClientSecret) ->
validated_refresh_credential(Token, ClientId, ClientSecret).
-endif.