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.
@@ -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='')}"