ark-oauth-client 2.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ark_oauth_client/__init__.py +180 -0
- ark_oauth_client/access/__init__.py +39 -0
- ark_oauth_client/access/denied_page.py +102 -0
- ark_oauth_client/access/endpoints.py +82 -0
- ark_oauth_client/access/gate.py +111 -0
- ark_oauth_client/access/options.py +273 -0
- ark_oauth_client/access/state.py +103 -0
- ark_oauth_client/auth_config.py +223 -0
- ark_oauth_client/client.py +961 -0
- ark_oauth_client/config.py +236 -0
- ark_oauth_client/context.py +87 -0
- ark_oauth_client/crypto.py +81 -0
- ark_oauth_client/diagnostics.py +67 -0
- ark_oauth_client/discovery.py +141 -0
- ark_oauth_client/errors.py +112 -0
- ark_oauth_client/flask.py +1297 -0
- ark_oauth_client/flows/__init__.py +17 -0
- ark_oauth_client/flows/client_credentials.py +219 -0
- ark_oauth_client/flows/registration.py +196 -0
- ark_oauth_client/helper.py +114 -0
- ark_oauth_client/http.py +196 -0
- ark_oauth_client/jwks.py +142 -0
- ark_oauth_client/jwt.py +487 -0
- ark_oauth_client/pkce.py +58 -0
- ark_oauth_client/probe.py +345 -0
- ark_oauth_client/py.typed +0 -0
- ark_oauth_client/session.py +142 -0
- ark_oauth_client/tokens.py +157 -0
- ark_oauth_client-2.0.4.dist-info/METADATA +615 -0
- ark_oauth_client-2.0.4.dist-info/RECORD +32 -0
- ark_oauth_client-2.0.4.dist-info/WHEEL +4 -0
- ark_oauth_client-2.0.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ark-oauth-client — the OAuth 2.1 / OpenID Connect client for Python applications talking to an Ark
|
|
3
|
+
identity server.
|
|
4
|
+
|
|
5
|
+
There is one URL to configure: the issuer, ``{BaseUrl}/{TenantId}``. Every endpoint, signing key and
|
|
6
|
+
capability is read from the provider's discovery document, so an application does not need to be
|
|
7
|
+
redeployed when the provider rotates a key or moves an endpoint.
|
|
8
|
+
|
|
9
|
+
from flask import Flask
|
|
10
|
+
from ark_oauth_client.flask import add_ark_oidc_client, current_ark
|
|
11
|
+
|
|
12
|
+
app = Flask(__name__)
|
|
13
|
+
app.secret_key = os.environ["ARK_SESSION_SECRET"]
|
|
14
|
+
|
|
15
|
+
auth = add_ark_oidc_client(app, {
|
|
16
|
+
"authority": "https://idp.example.com/my_idp",
|
|
17
|
+
"client_id": "my-app",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
@app.get("/billing")
|
|
21
|
+
@auth.require_claims("billing.admin")
|
|
22
|
+
def billing():
|
|
23
|
+
return f"hello {current_ark.user['name']}"
|
|
24
|
+
|
|
25
|
+
The Flask integration is one way in; :class:`ArkOAuthClient` is the whole protocol on its own, for
|
|
26
|
+
CLIs, workers, Django, FastAPI and every flow without a browser.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .access import (
|
|
30
|
+
ArkAccessDeniedContext,
|
|
31
|
+
ArkAccessDeniedPage,
|
|
32
|
+
ArkAccessDeniedReasons,
|
|
33
|
+
ArkAccessDeniedState,
|
|
34
|
+
ArkAccessEvaluationContext,
|
|
35
|
+
ArkAccessGate,
|
|
36
|
+
ArkAccountSwitchOptions,
|
|
37
|
+
ArkChallengeProperties,
|
|
38
|
+
ArkClientEvents,
|
|
39
|
+
ArkClientOptions,
|
|
40
|
+
ArkDeniedAccount,
|
|
41
|
+
local_or_default,
|
|
42
|
+
)
|
|
43
|
+
from .auth_config import ArkAuthConfig, ArkCert, AUser, AUserInfo
|
|
44
|
+
from .client import ArkOAuthClient, AuthorizationRequest, create_ark_client
|
|
45
|
+
from .config import ArkConfig, DEFAULT_SCOPES, PrivateKeyJwt, normalize_config
|
|
46
|
+
from .context import ArkAuthContext
|
|
47
|
+
from .crypto import base64url_decode, base64url_encode, left_half_hash, random_token
|
|
48
|
+
from .diagnostics import ArkClaimReader, ArkJson, ArkJwt
|
|
49
|
+
from .discovery import ArkDiscoveryCache, MetadataResolver, discovery_urls
|
|
50
|
+
from .errors import (
|
|
51
|
+
ArkCallbackError,
|
|
52
|
+
ArkConfigError,
|
|
53
|
+
ArkError,
|
|
54
|
+
ArkNetworkError,
|
|
55
|
+
ArkOAuthError,
|
|
56
|
+
ArkTokenError,
|
|
57
|
+
)
|
|
58
|
+
from .flows import ArkClientCredentials, ArkRegistration, ArkRegistrationResult, ArkTokenResult
|
|
59
|
+
from .helper import AuthClientHelper
|
|
60
|
+
from .jwks import JwksCache
|
|
61
|
+
from .jwt import (
|
|
62
|
+
decode_jwt,
|
|
63
|
+
sign_jwt,
|
|
64
|
+
validate_claims,
|
|
65
|
+
validate_token_hashes,
|
|
66
|
+
verify_jwt,
|
|
67
|
+
verify_signature,
|
|
68
|
+
)
|
|
69
|
+
from .pkce import (
|
|
70
|
+
PkcePair,
|
|
71
|
+
code_challenge_for,
|
|
72
|
+
create_code_verifier,
|
|
73
|
+
create_nonce,
|
|
74
|
+
create_pkce_pair,
|
|
75
|
+
create_state,
|
|
76
|
+
)
|
|
77
|
+
from .probe import ArkProviderMetadata, ArkSetupModel, ArkSetupProbe
|
|
78
|
+
from .session import (
|
|
79
|
+
MemorySessionStore,
|
|
80
|
+
SessionStore,
|
|
81
|
+
create_session_id,
|
|
82
|
+
sign_session_id,
|
|
83
|
+
unsign_session_id,
|
|
84
|
+
)
|
|
85
|
+
from .tokens import TokenSet
|
|
86
|
+
|
|
87
|
+
__version__ = "2.0.4"
|
|
88
|
+
|
|
89
|
+
#: The PKCE helpers under the name the .NET package gives them.
|
|
90
|
+
PkceHelper = type(
|
|
91
|
+
"PkceHelper",
|
|
92
|
+
(),
|
|
93
|
+
{
|
|
94
|
+
"generate_code_verifier": staticmethod(create_code_verifier),
|
|
95
|
+
"generate_code_challenge": staticmethod(code_challenge_for),
|
|
96
|
+
"__doc__": "PKCE helpers, under the .NET client's name for them.",
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"__version__",
|
|
102
|
+
# protocol client
|
|
103
|
+
"ArkOAuthClient",
|
|
104
|
+
"AuthorizationRequest",
|
|
105
|
+
"create_ark_client",
|
|
106
|
+
"TokenSet",
|
|
107
|
+
# configuration
|
|
108
|
+
"ArkConfig",
|
|
109
|
+
"ArkAuthConfig",
|
|
110
|
+
"ArkCert",
|
|
111
|
+
"AUser",
|
|
112
|
+
"AUserInfo",
|
|
113
|
+
"PrivateKeyJwt",
|
|
114
|
+
"normalize_config",
|
|
115
|
+
"DEFAULT_SCOPES",
|
|
116
|
+
# discovery and keys
|
|
117
|
+
"MetadataResolver",
|
|
118
|
+
"ArkDiscoveryCache",
|
|
119
|
+
"discovery_urls",
|
|
120
|
+
"JwksCache",
|
|
121
|
+
# tokens
|
|
122
|
+
"decode_jwt",
|
|
123
|
+
"verify_jwt",
|
|
124
|
+
"verify_signature",
|
|
125
|
+
"validate_claims",
|
|
126
|
+
"validate_token_hashes",
|
|
127
|
+
"sign_jwt",
|
|
128
|
+
# pkce
|
|
129
|
+
"PkcePair",
|
|
130
|
+
"PkceHelper",
|
|
131
|
+
"create_code_verifier",
|
|
132
|
+
"code_challenge_for",
|
|
133
|
+
"create_pkce_pair",
|
|
134
|
+
"create_state",
|
|
135
|
+
"create_nonce",
|
|
136
|
+
"base64url_encode",
|
|
137
|
+
"base64url_decode",
|
|
138
|
+
"left_half_hash",
|
|
139
|
+
"random_token",
|
|
140
|
+
# sessions
|
|
141
|
+
"SessionStore",
|
|
142
|
+
"MemorySessionStore",
|
|
143
|
+
"create_session_id",
|
|
144
|
+
"sign_session_id",
|
|
145
|
+
"unsign_session_id",
|
|
146
|
+
# diagnostics
|
|
147
|
+
"ArkSetupProbe",
|
|
148
|
+
"ArkSetupModel",
|
|
149
|
+
"ArkProviderMetadata",
|
|
150
|
+
"ArkJwt",
|
|
151
|
+
"ArkJson",
|
|
152
|
+
"ArkClaimReader",
|
|
153
|
+
"ArkAuthContext",
|
|
154
|
+
# flows
|
|
155
|
+
"ArkClientCredentials",
|
|
156
|
+
"ArkTokenResult",
|
|
157
|
+
"ArkRegistration",
|
|
158
|
+
"ArkRegistrationResult",
|
|
159
|
+
"AuthClientHelper",
|
|
160
|
+
# account switching
|
|
161
|
+
"ArkAccountSwitchOptions",
|
|
162
|
+
"ArkAccessDeniedReasons",
|
|
163
|
+
"ArkAccessDeniedContext",
|
|
164
|
+
"ArkAccessEvaluationContext",
|
|
165
|
+
"ArkAccessDeniedPage",
|
|
166
|
+
"ArkAccessDeniedState",
|
|
167
|
+
"ArkAccessGate",
|
|
168
|
+
"ArkChallengeProperties",
|
|
169
|
+
"ArkClientEvents",
|
|
170
|
+
"ArkClientOptions",
|
|
171
|
+
"ArkDeniedAccount",
|
|
172
|
+
"local_or_default",
|
|
173
|
+
# errors
|
|
174
|
+
"ArkError",
|
|
175
|
+
"ArkConfigError",
|
|
176
|
+
"ArkOAuthError",
|
|
177
|
+
"ArkTokenError",
|
|
178
|
+
"ArkCallbackError",
|
|
179
|
+
"ArkNetworkError",
|
|
180
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Account switching on a shared browser, and the access-denied page that makes it recoverable.
|
|
3
|
+
|
|
4
|
+
Mirrors the ``Access/`` folder of the .NET client: the options, the entitlement gate, the encrypted
|
|
5
|
+
denial cookie, the built-in page and the guards its two POST endpoints need. The endpoints are
|
|
6
|
+
served by :mod:`ark_oauth_client.flask`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .denied_page import ArkAccessDeniedPage
|
|
10
|
+
from .endpoints import check_same_origin, local_or_default, read_field
|
|
11
|
+
from .gate import ArkAccessGate
|
|
12
|
+
from .options import (
|
|
13
|
+
ArkAccessDeniedContext,
|
|
14
|
+
ArkAccessDeniedReasons,
|
|
15
|
+
ArkAccessEvaluationContext,
|
|
16
|
+
ArkAccountSwitchOptions,
|
|
17
|
+
ArkChallengeProperties,
|
|
18
|
+
ArkClientEvents,
|
|
19
|
+
ArkClientOptions,
|
|
20
|
+
ArkDeniedAccount,
|
|
21
|
+
)
|
|
22
|
+
from .state import ArkAccessDeniedState
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ArkAccessDeniedContext",
|
|
26
|
+
"ArkAccessDeniedPage",
|
|
27
|
+
"ArkAccessDeniedReasons",
|
|
28
|
+
"ArkAccessDeniedState",
|
|
29
|
+
"ArkAccessEvaluationContext",
|
|
30
|
+
"ArkAccessGate",
|
|
31
|
+
"ArkAccountSwitchOptions",
|
|
32
|
+
"ArkChallengeProperties",
|
|
33
|
+
"ArkClientEvents",
|
|
34
|
+
"ArkClientOptions",
|
|
35
|
+
"ArkDeniedAccount",
|
|
36
|
+
"check_same_origin",
|
|
37
|
+
"local_or_default",
|
|
38
|
+
"read_field",
|
|
39
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The built-in dead end.
|
|
3
|
+
|
|
4
|
+
Self-contained HTML with inlined styles and no script: it has to render in an application that has
|
|
5
|
+
no template directory, no static files and a strict Content-Security-Policy, and it is the one page
|
|
6
|
+
a stuck user sees, so it cannot itself depend on anything.
|
|
7
|
+
|
|
8
|
+
Replace it by pointing ``access_denied_path`` at your own route and setting ``serve_default_page``
|
|
9
|
+
to false; ``ark_denied_account()`` gives your page the same information, and the two POST endpoints
|
|
10
|
+
keep working.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from html import escape
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .endpoints import local_or_default
|
|
19
|
+
from .options import ArkAccountSwitchOptions
|
|
20
|
+
|
|
21
|
+
__all__ = ["ArkAccessDeniedPage"]
|
|
22
|
+
|
|
23
|
+
_STYLE = (
|
|
24
|
+
":root{--bg:#f7f7f8;--surface:#fff;--border:#e2e2e6;--fg:#17171a;--muted:#6a6a73;"
|
|
25
|
+
"--accent:#4f46e5;--accent-fg:#fff}"
|
|
26
|
+
"@media(prefers-color-scheme:dark){:root{--bg:#111114;--surface:#1a1a1f;--border:#313139;"
|
|
27
|
+
"--fg:#ececf1;--muted:#9a9aa5;--accent:#8b85ff;--accent-fg:#16161c}}"
|
|
28
|
+
"*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;"
|
|
29
|
+
"justify-content:center;padding:24px;background:var(--bg);color:var(--fg);"
|
|
30
|
+
'font:15px/1.55 ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}'
|
|
31
|
+
".card{width:100%;max-width:480px;background:var(--surface);border:1px solid var(--border);"
|
|
32
|
+
"border-radius:12px;padding:28px}"
|
|
33
|
+
"h1{margin:0 0 10px;font-size:19px;font-weight:640}p{margin:0 0 14px;color:var(--muted)}"
|
|
34
|
+
"p.lede{color:var(--fg)}"
|
|
35
|
+
"form{margin:0}.actions{display:flex;flex-direction:column;gap:10px;margin-top:20px}"
|
|
36
|
+
"button{width:100%;padding:11px 14px;border-radius:8px;border:1px solid var(--accent);"
|
|
37
|
+
"background:var(--accent);color:var(--accent-fg);font:inherit;font-weight:560;cursor:pointer}"
|
|
38
|
+
"button.secondary{background:transparent;color:var(--fg);border-color:var(--border)}"
|
|
39
|
+
".foot{margin-top:18px;padding-top:14px;border-top:1px solid var(--border);font-size:13px;"
|
|
40
|
+
"color:var(--muted)}.foot a{color:var(--accent)}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArkAccessDeniedPage:
|
|
45
|
+
"""Renders the page. Returns HTML; the caller decides the status and the headers."""
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def build(
|
|
49
|
+
options: ArkAccountSwitchOptions,
|
|
50
|
+
app: Optional[str],
|
|
51
|
+
account: Optional[str],
|
|
52
|
+
return_url: str,
|
|
53
|
+
) -> str:
|
|
54
|
+
app_name = escape(app.strip() if app and app.strip() else "this application")
|
|
55
|
+
target = escape(return_url)
|
|
56
|
+
home = escape(local_or_default(options.home_path, "/"))
|
|
57
|
+
|
|
58
|
+
lede = (
|
|
59
|
+
f"The account you are signed in with does not have access to {app_name}."
|
|
60
|
+
if account is None
|
|
61
|
+
else (
|
|
62
|
+
f"You are signed in as <strong>{escape(account)}</strong>, and that account does "
|
|
63
|
+
f"not have access to {app_name}."
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
parts = [
|
|
68
|
+
'<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">',
|
|
69
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
70
|
+
f"<title>No access — {app_name}</title><style>{_STYLE}</style></head>",
|
|
71
|
+
'<body><main class="card">',
|
|
72
|
+
f"<h1>You do not have access to {app_name}</h1>",
|
|
73
|
+
f'<p class="lede">{lede}</p>',
|
|
74
|
+
"<p>If somebody else was using this browser, their sign-in is still active and it is "
|
|
75
|
+
f"the one being offered to {app_name}. Choose “Sign in as a different user” "
|
|
76
|
+
"to enter your own credentials.</p>",
|
|
77
|
+
'<div class="actions">',
|
|
78
|
+
f'<form method="post" action="{escape(options.switch_user_path)}">',
|
|
79
|
+
f'<input type="hidden" name="returnUrl" value="{target}">',
|
|
80
|
+
'<button type="submit">Sign in as a different user</button></form>',
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
if options.allow_full_sign_out:
|
|
84
|
+
parts += [
|
|
85
|
+
f'<form method="post" action="{escape(options.sign_out_path)}">',
|
|
86
|
+
f'<input type="hidden" name="returnUrl" value="{home}">',
|
|
87
|
+
'<button class="secondary" type="submit">Sign out completely</button></form>',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
parts.append("</div>")
|
|
91
|
+
|
|
92
|
+
if options.support_url or options.support_email:
|
|
93
|
+
parts.append(f'<p class="foot">Need access to {app_name}? ')
|
|
94
|
+
if options.support_url:
|
|
95
|
+
parts.append(f'<a href="{escape(options.support_url)}">Request it here</a>')
|
|
96
|
+
else:
|
|
97
|
+
address = escape(options.support_email or "")
|
|
98
|
+
parts.append(f'<a href="mailto:{address}">{address}</a>')
|
|
99
|
+
parts.append("</p>")
|
|
100
|
+
|
|
101
|
+
parts.append("</main></body></html>")
|
|
102
|
+
return "".join(parts)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The guards the three account endpoints need, kept away from any one web framework.
|
|
3
|
+
|
|
4
|
+
The endpoints themselves are registered by :mod:`ark_oauth_client.flask`; what lives here is the
|
|
5
|
+
part that has to be right no matter who serves them — the open-redirect guard on a return URL, and
|
|
6
|
+
the same-origin check on the two POSTs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
__all__ = ["local_or_default", "check_same_origin", "read_field"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def local_or_default(url: Optional[str], fallback: Optional[str]) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Keeps a return URL inside this application. Echoing an arbitrary one would turn the
|
|
19
|
+
access-denied page into an open redirect, which is a phishing primitive.
|
|
20
|
+
"""
|
|
21
|
+
home = fallback if fallback and fallback.strip() else "/"
|
|
22
|
+
if not url or not url.strip():
|
|
23
|
+
return home
|
|
24
|
+
candidate = url.strip()
|
|
25
|
+
if not candidate.startswith("/"):
|
|
26
|
+
return home
|
|
27
|
+
if candidate.startswith("//"): # protocol-relative
|
|
28
|
+
return home
|
|
29
|
+
if candidate.startswith("/\\"): # backslash variant
|
|
30
|
+
return home
|
|
31
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in candidate):
|
|
32
|
+
return home
|
|
33
|
+
return candidate
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_same_origin(
|
|
37
|
+
method: str,
|
|
38
|
+
headers: Mapping[str, str],
|
|
39
|
+
origin_of_this_request: str,
|
|
40
|
+
) -> Tuple[bool, int, str]:
|
|
41
|
+
"""
|
|
42
|
+
POST, from this origin.
|
|
43
|
+
|
|
44
|
+
Neither endpoint is a security boundary — the worst a forged request achieves is an unwanted
|
|
45
|
+
sign-out — but a cross-site page should not be able to end a user's session, and requiring a
|
|
46
|
+
POST from the same origin costs nothing. Returns ``(ok, status, message)``.
|
|
47
|
+
"""
|
|
48
|
+
if method.upper() != "POST":
|
|
49
|
+
return False, 405, "POST required."
|
|
50
|
+
|
|
51
|
+
def header(name: str) -> str:
|
|
52
|
+
for key, value in headers.items():
|
|
53
|
+
if key.lower() == name:
|
|
54
|
+
return value or ""
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
fetch_site = header("sec-fetch-site")
|
|
58
|
+
if fetch_site:
|
|
59
|
+
if fetch_site not in ("same-origin", "none"):
|
|
60
|
+
return False, 400, "cross-site request rejected."
|
|
61
|
+
return True, 200, ""
|
|
62
|
+
|
|
63
|
+
origin = header("origin")
|
|
64
|
+
if not origin:
|
|
65
|
+
return True, 200, "" # not a browser form post
|
|
66
|
+
if origin.rstrip("/").lower() == origin_of_this_request.rstrip("/").lower():
|
|
67
|
+
return True, 200, ""
|
|
68
|
+
return False, 400, "cross-site request rejected."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_field(form: Optional[Mapping[str, Any]], query: Optional[Mapping[str, Any]], name: str):
|
|
72
|
+
"""
|
|
73
|
+
Form first, then the query string.
|
|
74
|
+
|
|
75
|
+
A null-coalescing chain would stop at an empty form value and never look at the query, which is
|
|
76
|
+
how the return URL goes missing on the one request that needs it.
|
|
77
|
+
"""
|
|
78
|
+
value = (form or {}).get(name)
|
|
79
|
+
if value:
|
|
80
|
+
return str(value)
|
|
81
|
+
value = (query or {}).get(name)
|
|
82
|
+
return str(value) if value else None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decides, once per sign-in, whether the account the provider authenticated is entitled to this
|
|
3
|
+
application.
|
|
4
|
+
|
|
5
|
+
The check belongs at the callback rather than at each protected view. An entitlement failure is not
|
|
6
|
+
"this user may not open that page", it is "this user may not use this application at all", and the
|
|
7
|
+
difference is visible in what happens next: refuse at the callback and no session cookie is ever
|
|
8
|
+
written, so the person at the keyboard is not left holding somebody else's session while every page
|
|
9
|
+
they open answers 403.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, List, Mapping, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from ..diagnostics import ArkClaimReader
|
|
17
|
+
from .options import (
|
|
18
|
+
ArkAccessDeniedContext,
|
|
19
|
+
ArkAccessEvaluationContext,
|
|
20
|
+
ArkAccountSwitchOptions,
|
|
21
|
+
ArkClientEvents,
|
|
22
|
+
ArkDeniedAccount,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["ArkAccessGate"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArkAccessGate:
|
|
29
|
+
"""The entitlement decision and the shape of the denial that follows it."""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def allowed(
|
|
33
|
+
options: ArkAccountSwitchOptions,
|
|
34
|
+
events: Optional[ArkClientEvents],
|
|
35
|
+
claims: Optional[Mapping[str, Any]],
|
|
36
|
+
ark_claims: Sequence[str],
|
|
37
|
+
*,
|
|
38
|
+
tokens: Any = None,
|
|
39
|
+
) -> bool:
|
|
40
|
+
required = options.required_claims or []
|
|
41
|
+
allowed = (not options.require_ark_claims) or (
|
|
42
|
+
any(c.lower() in {r.lower() for r in required} for c in ark_claims)
|
|
43
|
+
if required
|
|
44
|
+
else len(ark_claims) > 0
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if events is None or events.on_evaluate_access is None:
|
|
48
|
+
return allowed
|
|
49
|
+
|
|
50
|
+
# The host's rule replaces the configured one outright rather than being ANDed with it — a
|
|
51
|
+
# handler that wants both can read allowed_by_configuration and say so.
|
|
52
|
+
return bool(
|
|
53
|
+
events.on_evaluate_access(
|
|
54
|
+
ArkAccessEvaluationContext(
|
|
55
|
+
claims=dict(claims or {}),
|
|
56
|
+
ark_claims=list(ark_claims),
|
|
57
|
+
allowed_by_configuration=allowed,
|
|
58
|
+
tokens=tokens,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def describe(
|
|
65
|
+
reason: str,
|
|
66
|
+
claims: Optional[Mapping[str, Any]],
|
|
67
|
+
ark_claims: Sequence[str],
|
|
68
|
+
return_url: Optional[str],
|
|
69
|
+
) -> ArkAccessDeniedContext:
|
|
70
|
+
"""Builds the denial context, reading the account off whatever identity we do have."""
|
|
71
|
+
|
|
72
|
+
def first(*names: str) -> Optional[str]:
|
|
73
|
+
for name in names:
|
|
74
|
+
value = (claims or {}).get(name)
|
|
75
|
+
if value:
|
|
76
|
+
return str(value)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
return ArkAccessDeniedContext(
|
|
80
|
+
reason=reason,
|
|
81
|
+
subject=first("sub"),
|
|
82
|
+
email=first("email", "preferred_username"),
|
|
83
|
+
name=first("name"),
|
|
84
|
+
ark_claims=list(ark_claims),
|
|
85
|
+
return_url=return_url,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def to_account(denied: ArkAccessDeniedContext) -> ArkDeniedAccount:
|
|
90
|
+
return ArkDeniedAccount(
|
|
91
|
+
subject=denied.subject,
|
|
92
|
+
email=denied.email,
|
|
93
|
+
name=denied.name,
|
|
94
|
+
reason=denied.reason,
|
|
95
|
+
return_url=denied.return_url,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def read_claims(access_token: Optional[str]) -> List[str]:
|
|
100
|
+
"""The authorization claims Ark issued for this client, read off the access token."""
|
|
101
|
+
return ArkClaimReader.read_ark_claims(access_token)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def denied_url(options: ArkAccountSwitchOptions, return_url: Optional[str]) -> str:
|
|
105
|
+
from urllib.parse import quote
|
|
106
|
+
|
|
107
|
+
path = options.access_denied_path or "/ark/no-access"
|
|
108
|
+
if not return_url:
|
|
109
|
+
return path
|
|
110
|
+
separator = "&" if "?" in path else "?"
|
|
111
|
+
return f"{path}{separator}returnUrl={quote(return_url, safe='')}"
|