#!/usr/bin/env python3
"""
Which public OAuth providers advertise a standard way to check whether a token
is still good?

Reads each provider's OAuth/OIDC discovery document and records whether it
advertises revocation_endpoint (RFC 7009) and introspection_endpoint (RFC 7662).

Read-only. Fetches public .well-known documents over GET. No credentials, no
side effects, nothing mutating. Python 3 standard library only.

WHAT A "no" MEANS, AND WHAT IT DOES NOT
    A "no" means the provider does not advertise that endpoint in the document a
    spec-following client discovers. It does NOT mean the provider cannot revoke
    or validate a token. Several in this list can, through vendor-specific APIs
    that no discovery document points at: Google's tokeninfo, Slack's auth.test
    and auth.revoke, Twitch's /oauth2/validate, Discord's oauth2/@me, Dropbox's
    token/revoke, Microsoft Graph revokeSignInSessions plus continuous access
    evaluation. The gap being measured is discoverability, not capability.

SCOPE
    Public SaaS APIs that agents commonly call. Dedicated identity products
    (Okta, Auth0, PingOne, Keycloak) are per-tenant and generally DO advertise
    both endpoints; this sample says nothing about them.

Usage:  python3 part2-probe-oauth-discovery.py
"""

import json
import ssl
import sys
import urllib.error
import urllib.request
from datetime import date

PROVIDERS = [
    ("Adobe",      "https://ims-na1.adobelogin.com"),
    ("Apple",      "https://appleid.apple.com"),
    ("Atlassian",  "https://auth.atlassian.com"),
    ("Discord",    "https://discord.com"),
    ("Dropbox",    "https://www.dropbox.com"),
    ("GitLab",     "https://gitlab.com"),
    ("Google",     "https://accounts.google.com"),
    ("Intuit",     "https://developer.api.intuit.com"),
    ("LinkedIn",   "https://www.linkedin.com/oauth"),
    ("Microsoft",  "https://login.microsoftonline.com/common/v2.0"),
    ("OneLogin",   "https://openid-connect.onelogin.com/oidc/2"),
    ("PayPal",     "https://www.paypalobjects.com"),
    ("Salesforce", "https://login.salesforce.com"),
    ("SAP",        "https://accounts.sap.com"),
    ("Slack",      "https://slack.com"),
    ("Spotify",    "https://accounts.spotify.com"),
    ("Twitch",     "https://id.twitch.tv/oauth2"),
    ("Xero",       "https://identity.xero.com"),
    ("Yahoo",      "https://api.login.yahoo.com"),
    ("Zoom",       "https://zoom.us"),
]

# Deliberately excluded, with the reason, so the sample is not quietly shaped.
EXCLUDED = [
    ("token.actions.githubusercontent.com",
     "ID-token-only issuer for Actions workload identity. No token endpoint, "
     "so not an OAuth authorization server. GitHub's user-facing OAuth publishes "
     "no discovery document at all, though it does document token check and "
     "delete APIs."),
    ("login.okta.com",
     "Okta's own sign-in org, implicit and id_token only. Not representative of "
     "an Okta customer authorization server, which does advertise both."),
    ("Reddit, Shopify, ServiceNow, PingOne, Keycloak demo, Cognito",
     "Returned no valid discovery document (403, 404, or HTML) at either "
     "well-known path."),
]

WELL_KNOWN = ("/.well-known/openid-configuration",
              "/.well-known/oauth-authorization-server")

TIMEOUT = 20
UA = "oauth-discovery-probe (+https://laxsharma.com)"


def discover(base):
    """Return (metadata_dict, path_used) for the first well-known path that
    returns 200 and parses as a JSON object, else (None, None)."""
    ctx = ssl.create_default_context()
    for path in WELL_KNOWN:
        req = urllib.request.Request(base + path, headers={"User-Agent": UA})
        try:
            with urllib.request.urlopen(req, timeout=TIMEOUT, context=ctx) as r:
                if r.status != 200:
                    continue
                doc = json.loads(r.read().decode("utf-8"))
                if isinstance(doc, dict):
                    return doc, path
        except (urllib.error.URLError, json.JSONDecodeError, ValueError, OSError):
            continue
    return None, None


def main():
    rows, unreachable = [], []
    for name, base in PROVIDERS:
        doc, path = discover(base)
        if doc is None:
            unreachable.append(name)
            continue
        rows.append((name,
                     bool(doc.get("revocation_endpoint")),
                     bool(doc.get("introspection_endpoint")),
                     path))

    print(f"OAuth discovery probe, run {date.today().isoformat()}")
    print(f"{len(rows)} of {len(PROVIDERS)} providers returned a valid "
          f"discovery document.\n")
    print(f"{'PROVIDER':<12} {'RFC 7009 revocation':<21} {'RFC 7662 introspection'}")
    print("-" * 58)
    for name, rev, intro, _ in sorted(rows):
        print(f"{name:<12} {('yes' if rev else 'no'):<21} {'yes' if intro else 'no'}")
    print("-" * 58)

    n = len(rows)
    rev = [r[0] for r in rows if r[1]]
    intro = [r[0] for r in rows if r[2]]
    both = [r[0] for r in rows if r[1] and r[2]]
    neither = [r[0] for r in rows if not r[1] and not r[2]]

    print(f"revocation_endpoint     {len(rev)}/{n}   {', '.join(sorted(rev))}")
    print(f"introspection_endpoint  {len(intro)}/{n}   {', '.join(sorted(intro))}")
    print(f"both                    {len(both)}/{n}   {', '.join(sorted(both))}")
    print(f"neither                 {len(neither)}/{n}   {', '.join(sorted(neither))}")
    if unreachable:
        print(f"\nno valid document this run: {', '.join(unreachable)}")

    print("\nExcluded from the sample, and why:")
    for who, why in EXCLUDED:
        print(f"  {who}\n    {why}")
    print("\nA 'no' means not advertised in the discovery document. It does not "
          "mean\nthe provider cannot revoke or validate a token. See the module "
          "docstring.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
