diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..f1373fb7 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,17 @@ +{ + "projects": { + "default": "bdaya-oidc" + }, + "targets": { + "bdaya-oidc": { + "hosting": { + "oidc-flutter-example": [ + "oidc-flutter-example" + ], + "oidc-flutter-docs": [ + "oidc-flutter-docs" + ] + } + } + } +} diff --git a/.gitguardian.yaml b/.gitguardian.yaml new file mode 100644 index 00000000..5db0ab28 --- /dev/null +++ b/.gitguardian.yaml @@ -0,0 +1,9 @@ +version: 2 + +secret: + # PR #459 / #386 review: these are test-only mTLS fixtures generated by + # packages/oidc_core/test/fixtures/mtls/generate.sh for the RFC 8705 + # client-certificate-authentication test suite. They are not real secrets + # and are never used outside `dart test`. + ignored_paths: + - 'packages/oidc_core/test/fixtures/mtls/*.key' diff --git a/.github/actions/flutter_base/action.yaml b/.github/actions/flutter_base/action.yaml index 51ca33e5..c2a1c9e0 100644 --- a/.github/actions/flutter_base/action.yaml +++ b/.github/actions/flutter_base/action.yaml @@ -7,25 +7,188 @@ inputs: default: "stable" flutter-version: description: "Flutter version to use" - default: "3.22.0" + # >=3.41 required: the SwiftPM (FlutterFramework) plugin layout in + # oidc_darwin needs Flutter 3.41+ / Dart 3.11+. + default: "3.47.6" runs: using: "composite" steps: + # Resolve the exact (channel, version, arch, hash) this run will use, + # and the cache coordinates subosito/flutter-action would itself use for + # them, WITHOUT installing anything yet. `dry-run: true` short-circuits + # only flutter-action's own "Run setup script" step; its "Set action + # inputs" step always runs in print-only mode regardless and is what + # sets the CACHE-PATH/CACHE-KEY outputs read below. `cache`/`pub-cache` + # are both false here: we only want these outputs, never flutter- + # action's own cache steps -- see the next step's comment for why. + - name: Resolve Flutter SDK version + cache coordinates + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + channel: ${{ inputs.channel }} + flutter-version: ${{ inputs.flutter-version }} + cache: false + pub-cache: "false" + dry-run: true + + # subosito/flutter-action's OWN `cache: true` (an internal actions/cache + # step keyed on the same CACHE-KEY/CACHE-PATH resolved above) is broken + # on macOS and Windows runners: a long-standing, still-open upstream bug + # (subosito/flutter-action#350 -- multiple independent reporters, + # including one whose experience exactly matches ours: "Caches + # perfectly for android builds ('linux') but fails for macOS"). By the + # time its cache step's POST (save) phase runs at the end of the job, it + # cannot re-resolve the `${{ steps.flutter-action.outputs.CACHE-PATH }}` + # expression its own `with: path:` depends on, logs "Input required and + # not supplied: path", and silently no-ops -- so the SDK is NEVER + # persisted and every run on those OSes re-downloads+extracts it from + # scratch (65s+ on macOS, worse under load). Confirmed live on this + # repo's own main-branch runs (e.g. run 37523659563, 2026-10-06): the + # windows/macos/ios "Cache Flutter" POST step prints exactly that + # warning every time, while the Linux jobs (unit_core, linux, android, + # web) -- which go through this exact same flutter_base action -- cache + # and restore it just fine, because they hit an already-warm cache and + # never even reach the broken save path. + # + # The bug is NOT specific to flutter-action's own internal step, or to + # being nested inside a third-party composite action: a first attempt + # at this fix routed the same key/path through our own, non-nested + # actions/cache step below, referencing them via + # `${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}` (an output of the + # step above) directly in its `with:` -- and hit the EXACT SAME "Input + # required and not supplied: path" warning on macOS/Windows (confirmed + # live, run 37532075225). The actual trigger is narrower: a `with:` + # expression in ANY step (ours or a third party's) that reads + # `steps..outputs.*` cannot be re-resolved when that step's POST + # hook runs at the end of the job, on macOS/Windows runners specifically + # -- regardless of nesting. Our own pub-cache step below has never hit + # this because its `key`/`path` only reference flat, non-output values + # (`inputs.channel`, `runner.os`, `hashFiles(...)`), which DO replay + # correctly in POST on every OS (its POST logs a real "Failed to save: + # ... another job may be creating this cache" race, never an + # empty-input warning). + # + # Fix: copy the two outputs we need into plain environment variables via + # $GITHUB_ENV right away, then have the restore/save steps below + # reference `env.*` instead of `steps.flutter_sdk_info.outputs.*`. Env-var-backed + # context (`env.*`, `inputs.*`) is a flat key/value store the runner + # persists for the rest of the job and replays correctly in POST on + # every OS; the `steps.*.outputs` context is the piece that doesn't + # survive the replay. + - name: Export Flutter SDK cache coordinates + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: Restore Flutter SDK cache + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - cache: true + # Both false: the SDK cache is handled entirely by our own step + # above (flutter-action's internal version is the broken one on + # macOS/Windows -- see that step's comment). `cache: true` with no + # explicit `pub-cache` input would ALSO silently enable flutter- + # action's OWN internal pub-cache step (its action.yaml: `if: + # (inputs.pub-cache == '' && inputs.cache == 'true') || ...`), whose + # key unconditionally appends `${{ hashFiles('**/pubspec.lock') }}` + # -- an UNSCOPED glob from the repo root, not the narrower + # `packages/**/pubspec.yaml` below. Confirmed live: a run on this + # exact composite action hit "hashFiles('**/pubspec.lock') couldn't + # finish within 120 seconds" on a macOS leg, and the GH Actions + # cache listing (`gh api repos/Bdaya-Dev/oidc/actions/caches`) + # showed a `flutter-linux-...--` key with an EMPTY trailing + # segment -- the same hashFiles call degrading to "" on Linux too, + # just not fatally there. pub-cache: "false" keeps that disabled + # regardless (we run our own narrowly-scoped pub cache below + # instead) for every job that goes through this action, including + # ios/macos/android/web/linux/windows via integration_test_base -- + # without changing anything those jobs own. + cache: false + pub-cache: "false" channel: ${{ inputs.channel }} flutter-version: ${{ inputs.flutter-version }} + # Save, split from restore above (actions/cache/save, not the combined + # actions/cache action), and gated to pushes on main only: every other + # ref (PR runs, workflow_dispatch runs like the ones used to verify this + # fix) restores whatever main has already populated but never saves its + # own copy. The SDK archive is multi-GB per OS (~1.7-2.2GB each); since + # this repo's total GH Actions cache is capped at 10GB and already + # shares that budget with the pub cache above and the Gradle caches the + # android job writes, letting every PR/dispatch run freely create its + # own branch-scoped SDK entry would grow the total without bound (GH + # cache entries are scoped per-ref, so PR branches don't share main's + # entries for writes, only for reads). A `restore-keys` fallback isn't + # meaningful here either way -- a different (channel, version, arch, + # hash) tuple is simply a different, non-interchangeable SDK build, not + # a partial match worth falling back to. + # Split into separate restore/save actions (rather than the combined + # actions/cache@v6 used for the pub cache below) specifically so this + # step runs and completes synchronously, right here, instead of + # deferring the actual save to a POST hook at the end of the job: no + # POST-phase replay is involved at all, which sidesteps the whole class + # of bug described on the restore step above (both actions/cache/ + # restore and actions/cache/save are plain `main`-only actions with no + # `post:` step). + - name: Save Flutter SDK cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + + # Replaces the internal pub-cache step disabled above. subosito/ + # flutter-action's own `cache: true` only caches the downloaded + # Flutter/Dart SDK (keyed by version), not the hosted-package pub cache + # that `melos bootstrap` below populates via `pub get` across every + # workspace package, nor the pub-global packages activated later by + # the coverage-formatting steps (coverage/combine_coverage/ + # remove_from_coverage). Missing previously (every job paid a cold + # `pub get` + cold global-package resolve/compile on every run). Now + # that unit_tests is split into many parallel jobs (see tests.yaml), + # this matters more, not less: every one of those jobs runs its own + # `melos bootstrap` independently, so a warm cache is shared across all + # of them (and across sdk channels via the key). + # Keyed on the channel (stable/beta resolve different dependency sets) + # and a hash of every pubspec, scoped under packages/ (cheap: at this + # point in the job nothing has generated android/Pods/build dirs yet, + # so the glob only walks source files) instead of an unscoped `**` from + # the repo root -- a dependency change invalidates it; restore-keys + # fall back to the most recent cache for the same channel so a partial + # hit still saves most of the network/compile cost. Concurrent jobs + # racing to save the same key is harmless: actions/cache only lets the + # first writer persist it, and the pub cache's own content is derived + # purely from pubspec.yaml/pubspec.lock, so two independent + # populations of the same key are equivalent. + - name: Cache pub dependencies + uses: actions/cache@v6 + with: + # Two paths, not one: this action also runs on windows-latest (the + # `windows` job, via integration_test_base) where the global pub + # cache lives under %LOCALAPPDATA%\Pub\Cache + # (~/AppData/Local/Pub/Cache under this workflow's bash shell), not + # ~/.pub-cache -- see the windows job's own PATH export in + # tests.yaml, which adds both for the same reason. A path that + # doesn't exist on a given OS is simply skipped by actions/cache, + # so listing both is harmless on Linux/macOS. + path: | + ~/.pub-cache + ~/AppData/Local/Pub/Cache + key: pub-${{ runner.os }}-${{ inputs.channel }}-${{ hashFiles('pubspec.yaml', 'pubspec.lock', 'packages/**/pubspec.yaml') }} + restore-keys: | + pub-${{ runner.os }}-${{ inputs.channel }}- + # see https://github.com/invertase/melos/issues/796 - uses: bluefireteam/melos-action@main continue-on-error: true with: run-bootstrap: true - bootstrap-no-example: false - - uses: bluefireteam/melos-action@main - with: - run-bootstrap: true - bootstrap-no-example: true \ No newline at end of file diff --git a/.github/actions/integration_test_base/action.yaml b/.github/actions/integration_test_base/action.yaml index 836675b0..085b4e21 100644 --- a/.github/actions/integration_test_base/action.yaml +++ b/.github/actions/integration_test_base/action.yaml @@ -7,7 +7,9 @@ inputs: default: "stable" flutter-version: description: "Flutter version to use" - default: "3.22.0" + # >=3.41 required: the SwiftPM (FlutterFramework) plugin layout in + # oidc_darwin needs Flutter 3.41+ / Dart 3.11+. + default: "3.47.6" runs: using: "composite" diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5e2fc809..bb880721 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -30,17 +30,13 @@ updates: schedule: interval: "monthly" - package-ecosystem: "pub" - directory: "/packages/oidc_ios" + directory: "/packages/oidc_darwin" schedule: interval: "monthly" - package-ecosystem: "pub" directory: "/packages/oidc_linux" schedule: interval: "monthly" - - package-ecosystem: "pub" - directory: "/packages/oidc_macos" - schedule: - interval: "monthly" - package-ecosystem: "pub" directory: "/packages/oidc_web" schedule: diff --git a/.github/scripts/firebase_channel_deploy.sh b/.github/scripts/firebase_channel_deploy.sh new file mode 100644 index 00000000..8688e42b --- /dev/null +++ b/.github/scripts/firebase_channel_deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Deploy one Firebase Hosting preview channel with honest error classification. +# +# Why not FirebaseExtended/action-hosting-deploy: firebase-tools' apiv2 client +# retries POSTs after transient "premature close" network errors even though +# the channel-release POST is not idempotent. The first POST usually succeeds +# server-side, so the retry gets HTTP 400 FAILED_PRECONDITION ("supplied +# version ... is the current active version"). The action treats that as a +# hard failure and, when given a repoToken, writes a failing "Deploy Preview" +# check-run onto the PR even when the job is green. Here that error means +# "the content is already live" and is classified as success; anything else +# fails the step for real (no blanket continue-on-error). +# +# Inputs (env): FIREBASE_SERVICE_ACCOUNT, PROJECT_ID, TARGET, CHANNEL +# Outputs (GITHUB_OUTPUT): url= +set -uo pipefail + +: "${FIREBASE_SERVICE_ACCOUNT:?}" "${PROJECT_ID:?}" "${TARGET:?}" "${CHANNEL:?}" + +export GOOGLE_APPLICATION_CREDENTIALS="${RUNNER_TEMP}/firebase-sa-${TARGET}.json" +printf '%s' "$FIREBASE_SERVICE_ACCOUNT" > "$GOOGLE_APPLICATION_CREDENTIALS" +trap 'rm -f "$GOOGLE_APPLICATION_CREDENTIALS"' EXIT + +FIREBASE="npx --yes firebase-tools@15" + +set +e +out="$($FIREBASE hosting:channel:deploy "$CHANNEL" \ + --only "$TARGET" --expires 7d --project "$PROJECT_ID" --json 2>&1)" +code=$? +set -e +echo "$out" + +if [ "$code" -ne 0 ]; then + if grep -q "is the current active version" <<<"$out"; then + echo "::notice title=Redundant release::${TARGET}/${CHANNEL}: content already live; treating as success." + else + echo "::error title=Firebase preview deploy failed::target=${TARGET} channel=${CHANNEL}" + exit 1 + fi +fi + +# Channel URLs are stable per channel (https://---.web.app). +# Take it from the deploy output; on the redundant-release path fall back to +# the channel list. +url="$(grep -oE "https://[A-Za-z0-9-]+\.web\.app" <<<"$out" | grep -- "--${CHANNEL}-" | head -1 || true)" +if [ -z "$url" ]; then + list_out="$($FIREBASE hosting:channel:list --site "$TARGET" --project "$PROJECT_ID" 2>&1 || true)" + url="$(grep -oE "https://[A-Za-z0-9-]+\.web\.app" <<<"$list_out" | grep -- "--${CHANNEL}-" | head -1 || true)" +fi + +echo "url=${url}" >> "$GITHUB_OUTPUT" +{ + echo "### Firebase preview: \`${TARGET}\`" + if [ -n "$url" ]; then echo "${url} (expires 7d after last deploy)"; else echo "_deployed, but URL could not be determined_"; fi +} >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/ios_ci_metrics.py b/.github/scripts/ios_ci_metrics.py new file mode 100644 index 00000000..fb42cddc --- /dev/null +++ b/.github/scripts/ios_ci_metrics.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Summarise how loaded the iOS CI simulator host was, and how that showed up +in the browser flows (#469). + +Inputs, all optional (a missing file just drops its section): + --runner-log `log show` output for the Runner process (the harness's + "flutter: " print lines). + --launch-log `log show` output for runningboardd launch lines. + --host-load host-load.log from the sampler in tests.yaml. + +Everything is read AFTER the tests, from the simulator's persisted log store, +so measuring adds no load while the tests run. + +Metrics: + logins per-module login duration, harness "Starting login" -> + "[e2e] -> authResult". A healthy iOS login takes + ~3-5s; >10s is counted as delayed, >30s as a stall. + WebContent launch runningboardd "Executing launch request" for a + com.apple.WebKit.WebContent job -> the first line naming + that job's uuid with a pid ("Now tracking process"). + Healthy launches take ~1.5s. + host load average (1m), free pages, swapouts. +""" + +import argparse +import re +import statistics +import sys +from datetime import datetime + + +def _ts(line): + try: + return datetime.strptime(line[:23], '%Y-%m-%d %H:%M:%S.%f') + except ValueError: + return None + + +def logins(path): + start, name, rows = None, None, [] + for line in open(path, encoding='utf-8', errors='replace'): + if 'flutter: ' not in line: + continue + ts = _ts(line) + if ts is None: + continue + m = re.search(r'oidc\.conformance\.([\w-]+)\.\w+\] Starting login', line) + if m: + start, name = ts, m.group(1) + continue + # A discarded first attempt (the harness's gated rerun) never prints + # authResult; its RERUN line closes it instead, so the stall it was + # discarded for still counts here. + m = re.search(r'\[e2e\] ([\w-]+) -> (?:authResult|RERUN:)', line) + if m and start is not None and m.group(1) == name: + rows.append(((ts - start).total_seconds(), name, start)) + start = None + return rows + + +def launches(path): + pending, rows = {}, [] + for line in open(path, encoding='utf-8', errors='replace'): + ts = _ts(line) + if ts is None: + continue + m = re.search(r'\[uuid:([0-9A-F-]{36})\]', line) + if not m: + continue + uuid = m.group(1) + if ('Executing launch request' in line + and 'com.apple.WebKit.WebContent' in line): + pending[uuid] = ts + # The launched job's own lines carry "...{definition:…}:]". + elif uuid in pending and re.search(r'[\]}]:\d+\]', line): + rows.append(((ts - pending.pop(uuid)).total_seconds(), ts)) + return rows + + +def host(path): + load, free, swap = [], [], [] + for line in open(path, encoding='utf-8', errors='replace'): + m = re.match(r'\{ ([\d.]+) ', line) + if m: + load.append(float(m.group(1))) + m = re.match(r'Pages free:\s+(\d+)', line) + if m: + free.append(int(m.group(1))) + m = re.match(r'Swapouts:\s+(\d+)', line) + if m: + swap.append(int(m.group(1))) + return load, free, swap + + +def _dist(values): + if not values: + return 'n=0' + v = sorted(values) + return (f'n={len(v)} median={statistics.median(v):.1f}s ' + f'p90={v[int(len(v) * 0.9)]:.1f}s max={v[-1]:.1f}s') + + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--runner-log') + p.add_argument('--launch-log') + p.add_argument('--host-load') + a = p.parse_args() + out = [] + if a.runner_log: + try: + rows = logins(a.runner_log) + d = [r[0] for r in rows] + out.append(f'logins: {_dist(d)}; >10s: {sum(x > 10 for x in d)}; ' + f'>30s (stall): {sum(x > 30 for x in d)}') + for secs, name, start in sorted(rows, reverse=True)[:8]: + if secs > 10: + out.append(f' {secs:6.1f}s {name} @{start:%H:%M:%S}') + except OSError as e: + out.append(f'logins: unreadable ({e})') + if a.launch_log: + try: + rows = launches(a.launch_log) + d = [r[0] for r in rows] + out.append(f'WebContent launch: {_dist(d)}; >10s: ' + f'{sum(x > 10 for x in d)}') + except OSError as e: + out.append(f'WebContent launch: unreadable ({e})') + if a.host_load: + try: + load, free, swap = host(a.host_load) + if load: + out.append( + f'host: load1 median={statistics.median(load):.0f} ' + f'max={max(load):.0f}; pages free median=' + f'{statistics.median(free) if free else "?"} ' + f'min={min(free) if free else "?"}; swapouts end=' + f'{swap[-1] if swap else "?"}') + except OSError as e: + out.append(f'host: unreadable ({e})') + print('\n'.join('[ios-metrics] ' + line for line in out)) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 84335861..e50e970e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,7 +12,7 @@ jobs: # runs-on: 'ubuntu-latest' # steps: # - name: 📚 Git Checkout - # uses: actions/checkout@v4 + # uses: actions/checkout@v5 # - name: npm install # run: | # npm install -g git+https://github.com/streetsidesoftware/cspell-cli diff --git a/.github/workflows/dart_package.yaml b/.github/workflows/dart_package.yaml index 82ecadf8..878d54df 100644 --- a/.github/workflows/dart_package.yaml +++ b/.github/workflows/dart_package.yaml @@ -71,15 +71,66 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + # Resolve cache coordinates without installing (see flutter_base's + # "Resolve Flutter SDK version + cache coordinates" step for the full + # rationale): flutter-action's OWN `cache: true` step is a long- + # standing, still-open upstream bug on macOS/Windows runners + # (subosito/flutter-action#350) where its POST (save) phase cannot + # re-resolve its own `path` input and silently never persists the + # SDK. This reusable workflow isn't currently invoked by anything in + # this repo, but `runs_on` is caller-supplied, so it isn't + # Linux-only by construction either -- fixed here for consistency + # with flutter_base and the other callers of flutter-action below. + - name: 🐦 Resolve Flutter SDK version + cache coordinates + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{inputs.flutter_version}} + channel: ${{inputs.flutter_channel}} + cache: false + pub-cache: "false" + dry-run: true + + # `steps.flutter_sdk_info.outputs.*` cannot be referenced directly in + # the cache step's `with:` below: its POST (save) phase fails to + # re-resolve a `steps.*.outputs.*`-based expression on macOS/Windows + # runners (confirmed live on this repo, run 37532075225 -- see + # flutter_base's identical step for the full story). Copying the two + # values into env vars first sidesteps it: `env.*` is a flat + # key/value context that replays correctly in POST on every OS. + - name: 🐦 Export Flutter SDK cache coordinates + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: 🐦 Restore Flutter SDK cache + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} - name: 🐦 Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{inputs.flutter_version}} channel: ${{inputs.flutter_channel}} - cache: true - cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + cache: false + pub-cache: "false" + + # Save only on pushes to main -- see flutter_base's identical step + # for the full rationale (PR/dispatch runs restore but never write + # their own branch-scoped copy of this multi-GB entry). + - name: 🐦 Save Flutter SDK cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} - uses: bluefireteam/melos-action@v3 @@ -104,11 +155,11 @@ jobs: run: ${{inputs.custom_tests}} - name: Report to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - file: ${{inputs.working_directory}}/coverage/lcov.info + files: ${{inputs.working_directory}}/coverage/lcov.info fail_ci_if_error: false # - name: 📊 Check Code Coverage # uses: VeryGoodOpenSource/very_good_coverage@v2 diff --git a/.github/workflows/deploy_firebase.yaml b/.github/workflows/deploy_firebase.yaml new file mode 100644 index 00000000..77abc699 --- /dev/null +++ b/.github/workflows/deploy_firebase.yaml @@ -0,0 +1,178 @@ +name: Deploy Example to Firebase Hosting + +on: + push: + branches: ["main"] + paths: + - ".github/workflows/deploy_firebase.yaml" + - "firebase.json" + - ".firebaserc" + - "packages/oidc/example/**" + workflow_dispatch: + inputs: + oidc_issuer: + description: "OIDC issuer URL (used to derive the discovery document)" + required: false + type: string + default: "https://demo.duendesoftware.com" + oidc_client_id: + description: "OIDC clientId" + required: false + type: string + default: "interactive.public.short" + oidc_scopes: + description: "Scopes (comma and/or space separated)" + required: false + type: string + default: "openid profile email offline_access" + oidc_redirect_uri: + description: "Redirect URI (defaults to the Firebase hosted redirect.html)" + required: false + type: string + default: "" + oidc_post_logout_redirect_uri: + description: "Post logout redirect URI (defaults to the Firebase hosted redirect.html)" + required: false + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: "firebase-hosting" + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Resolve cache coordinates without installing -- see + # .github/actions/flutter_base's comment for the full story: + # flutter-action's OWN `cache: true` step is a long-standing, + # still-open upstream bug on macOS/Windows runners (subosito/ + # flutter-action#350); this job runs on ubuntu-latest only so it + # isn't directly hit, but its own internal pub-cache step's unscoped + # `hashFiles('**/pubspec.lock')` was observed failing ("couldn't + # finish within 120 seconds") on a macOS runner elsewhere in this + # repo's CI. Also drops the custom `cache-key` that omitted + # `:version:` -- it produced a cache entry duplicating (same bytes, + # different key: `flutter-linux-stable-x64-`) the one + # flutter_base already populates for the same channel/version/arch/ + # hash; using flutter-action's own default key (via dry-run below) + # unifies with that entry instead of duplicating it, which matters + # under this repo's 10GB total cache cap. + - name: Resolve Flutter SDK version + cache coordinates + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: false + pub-cache: "false" + dry-run: true + + # `steps.flutter_sdk_info.outputs.*` cannot be referenced directly in + # the cache step's `with:` below: its POST (save) phase fails to + # re-resolve a `steps.*.outputs.*`-based expression on macOS/Windows + # runners (confirmed live on this repo, run 37532075225 -- see + # flutter_base's identical step for the full story; this job runs on + # ubuntu-latest only, but the fix is applied here too for + # consistency and because `env.*` is what actually works). Copying + # the two values into env vars first sidesteps it. + - name: Export Flutter SDK cache coordinates + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: Restore Flutter SDK cache + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: false + pub-cache: "false" + + # Save only on pushes to main -- see flutter_base's identical step + # for the full rationale (this workflow's own workflow_dispatch runs + # restore but never write their own copy of this multi-GB entry). + - name: Save Flutter SDK cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + + - name: Build Example (Flutter web + wasm) + shell: bash + env: + OIDC_ISSUER: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.oidc_issuer) || 'https://demo.duendesoftware.com' }} + OIDC_CLIENT_ID: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.oidc_client_id) || 'interactive.public.short' }} + OIDC_SCOPES: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.oidc_scopes) || 'openid profile email offline_access' }} + OIDC_REDIRECT_URI_INPUT: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.oidc_redirect_uri) || '' }} + OIDC_POST_LOGOUT_REDIRECT_URI_INPUT: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.oidc_post_logout_redirect_uri) || '' }} + run: | + set -euo pipefail + + # For multi-site Firebase Hosting, the app is at the site root. + BASE_HREF="/" + + DEFAULT_REDIRECT_URI="redirect.html" + + if [ -n "${OIDC_REDIRECT_URI_INPUT}" ]; then + OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI_INPUT}" + else + OIDC_REDIRECT_URI="${DEFAULT_REDIRECT_URI}" + fi + + if [ -n "${OIDC_POST_LOGOUT_REDIRECT_URI_INPUT}" ]; then + OIDC_POST_LOGOUT_REDIRECT_URI="${OIDC_POST_LOGOUT_REDIRECT_URI_INPUT}" + else + OIDC_POST_LOGOUT_REDIRECT_URI="${DEFAULT_REDIRECT_URI}" + fi + + pushd packages/oidc/example + flutter pub get + flutter build web --release --wasm --base-href "${BASE_HREF}" \ + --dart-define=OIDC_ISSUER="${OIDC_ISSUER}" \ + --dart-define=OIDC_CLIENT_ID="${OIDC_CLIENT_ID}" \ + --dart-define=OIDC_SCOPES="${OIDC_SCOPES}" \ + --dart-define=OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI}" \ + --dart-define=OIDC_POST_LOGOUT_REDIRECT_URI="${OIDC_POST_LOGOUT_REDIRECT_URI}" + popd + + mkdir -p dist/oidc-example + rm -rf dist/oidc-example/* + cp -R packages/oidc/example/build/web/. dist/oidc-example/ + + - name: Setup Node (Firebase CLI) + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install Firebase CLI + run: npm i -g firebase-tools + + - name: Authenticate to Google Cloud + env: + FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC}" ]; then + echo "Missing secret: FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC" >&2 + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC}" > "$RUNNER_TEMP/gcp-sa.json" + echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/gcp-sa.json" >> $GITHUB_ENV + + - name: Deploy Example to Firebase Hosting + run: firebase deploy --project bdaya-oidc --only hosting:oidc-flutter-example diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 2e6bb278..95bb168d 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,5 +1,5 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy MkDocs to Pages +# Deploy MkDocs docs to GitHub Pages and Firebase Hosting +name: Deploy Docs on: # Runs on pushes targeting the default branch @@ -8,7 +8,8 @@ on: paths: - ".github/workflows/docs.yaml" - "docs/**" - - mkdocs.yml + - mkdocs.yml + - ".firebaserc" workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages @@ -28,26 +29,55 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Pages - uses: actions/configure-pages@v5 - - uses: actions/setup-python@v4 + uses: actions/configure-pages@v6 + - uses: actions/setup-python@v7 with: python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: - key: mkdocs-material-${{ env.cache_id }} + key: mkdocs-material-${{ runner.os }}-${{ hashFiles('mkdocs.yml') }} path: .cache restore-keys: | - mkdocs-material- + mkdocs-material-${{ runner.os }}- - run: pip install mkdocs mkdocs-material click-man - run: mkdocs build + + - name: Copy MkDocs site for Firebase + shell: bash + run: | + set -euo pipefail + rm -rf dist/oidc-docs + mkdir -p dist/oidc-docs + cp -R site/. dist/oidc-docs/ - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: # Upload specific path path: 'site' + + - name: Setup Node (Firebase CLI) + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install Firebase CLI + run: npm i -g firebase-tools + + - name: Authenticate to Google Cloud + env: + FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC}" ]; then + echo "Missing secret: FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC" >&2 + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC}" > "$RUNNER_TEMP/gcp-sa.json" + echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/gcp-sa.json" >> $GITHUB_ENV + + - name: Deploy Docs to Firebase Hosting + run: firebase deploy --project bdaya-oidc --only hosting:oidc-flutter-docs deploy: permissions: pages: write # to deploy to Pages @@ -60,4 +90,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/firebase_preview.yaml b/.github/workflows/firebase_preview.yaml new file mode 100644 index 00000000..1fd60a5e --- /dev/null +++ b/.github/workflows/firebase_preview.yaml @@ -0,0 +1,221 @@ +name: Firebase Hosting Previews (Docs + Example) + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - ".github/workflows/firebase_preview.yaml" + - "firebase.json" + - ".firebaserc" + - "docs/**" + - "mkdocs.yml" + - "packages/oidc/example/**" + +permissions: + contents: read + pull-requests: write # sticky preview-URL comment + +concurrency: + group: "firebase-preview-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + build-and-preview: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Only build+deploy a target whose sources actually changed in this PR. + # A Firebase Hosting preview release is non-idempotent: re-releasing a + # byte-identical version to a channel returns HTTP 400 FAILED_PRECONDITION + # ("supplied version is the current active version"). Gating each target + # on its own paths avoids most redundant releases; the deploy script + # classifies the residual ones (see .github/scripts/firebase_channel_deploy.sh). + - name: Detect changed paths + uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + docs: + - "docs/**" + - "mkdocs.yml" + example: + - "packages/oidc/example/**" + + # Resolve cache coordinates without installing -- see deploy_firebase. + # yaml's identical fix / .github/actions/flutter_base's comment for + # the full story: flutter-action's OWN `cache: true` step is a + # long-standing, still-open upstream bug on macOS/Windows runners + # (subosito/flutter-action#350); this job runs on ubuntu-latest only + # so it isn't directly hit, but its own internal pub-cache step's + # unscoped `hashFiles('**/pubspec.lock')` was observed failing + # ("couldn't finish within 120 seconds") on a macOS runner elsewhere + # in this repo's CI. Also drops the custom `cache-key` that omitted + # `:version:` -- it produced a cache entry duplicating (same bytes, + # different key) the one flutter_base already populates for the same + # channel/version/arch/hash; using flutter-action's own default key + # (via dry-run below) unifies with that entry instead of duplicating + # it, which matters under this repo's 10GB total cache cap. + - name: Resolve Flutter SDK version + cache coordinates + if: steps.filter.outputs.example == 'true' + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: false + pub-cache: "false" + dry-run: true + + # `steps.flutter_sdk_info.outputs.*` cannot be referenced directly in + # the cache step's `with:` below: its POST (save) phase fails to + # re-resolve a `steps.*.outputs.*`-based expression on macOS/Windows + # runners (confirmed live on this repo, run 37532075225 -- see + # flutter_base's identical step for the full story; this job runs on + # ubuntu-latest only, but the fix is applied here too for + # consistency and because `env.*` is what actually works). Copying + # the two values into env vars first sidesteps it. + - name: Export Flutter SDK cache coordinates + if: steps.filter.outputs.example == 'true' + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: Restore Flutter SDK cache + if: steps.filter.outputs.example == 'true' + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + + - name: Setup Flutter + if: steps.filter.outputs.example == 'true' + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: false + pub-cache: "false" + + # Save only on pushes to main -- see flutter_base's identical step + # for the full rationale (PR runs restore but never write their own + # branch-scoped copy of this multi-GB entry). + - name: Save Flutter SDK cache + if: ${{ steps.filter.outputs.example == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} + + - name: Setup Python + if: steps.filter.outputs.docs == 'true' + uses: actions/setup-python@v7 + with: + python-version: 3.x + + - name: Restore MkDocs cache + if: steps.filter.outputs.docs == 'true' + uses: actions/cache@v6 + with: + key: mkdocs-material-${{ runner.os }}-${{ hashFiles('mkdocs.yml') }} + path: .cache + restore-keys: | + mkdocs-material-${{ runner.os }}- + + - name: Install MkDocs + if: steps.filter.outputs.docs == 'true' + run: pip install mkdocs mkdocs-material click-man + + - name: Build Docs (MkDocs) + if: steps.filter.outputs.docs == 'true' + run: mkdocs build --site-dir dist/oidc-docs + + - name: Build Example (Flutter web + wasm) + if: steps.filter.outputs.example == 'true' + shell: bash + env: + OIDC_ISSUER: https://demo.duendesoftware.com + OIDC_CLIENT_ID: interactive.public.short + OIDC_SCOPES: openid profile email offline_access + run: | + set -euo pipefail + + BASE_HREF="/" + OIDC_REDIRECT_URI="redirect.html" + OIDC_POST_LOGOUT_REDIRECT_URI="redirect.html" + + pushd packages/oidc/example + flutter pub get + flutter build web --release --wasm --base-href "${BASE_HREF}" \ + --dart-define=OIDC_ISSUER="${OIDC_ISSUER}" \ + --dart-define=OIDC_CLIENT_ID="${OIDC_CLIENT_ID}" \ + --dart-define=OIDC_SCOPES="${OIDC_SCOPES}" \ + --dart-define=OIDC_REDIRECT_URI="${OIDC_REDIRECT_URI}" \ + --dart-define=OIDC_POST_LOGOUT_REDIRECT_URI="${OIDC_POST_LOGOUT_REDIRECT_URI}" + popd + + mkdir -p dist/oidc-example + rm -rf dist/oidc-example/* + cp -R packages/oidc/example/build/web/. dist/oidc-example/ + + # Direct firebase-tools CLI instead of FirebaseExtended/action-hosting-deploy: + # the script treats the retry-after-premature-close redundant-release 400 as + # success and fails honestly on anything else, and no repoToken means no + # spurious failing "Deploy Preview" check-run on the PR. + - name: Deploy Docs Preview to Firebase Hosting + id: deploy_docs + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs == 'true' }} + env: + FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC }} + PROJECT_ID: bdaya-oidc + TARGET: oidc-flutter-docs + CHANNEL: pr-${{ github.event.pull_request.number }}-docs + run: bash .github/scripts/firebase_channel_deploy.sh + + - name: Deploy Example Preview to Firebase Hosting + id: deploy_example + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.example == 'true' }} + env: + FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_BDAYA_OIDC }} + PROJECT_ID: bdaya-oidc + TARGET: oidc-flutter-example + CHANNEL: pr-${{ github.event.pull_request.number }}-example + run: bash .github/scripts/firebase_channel_deploy.sh + + - name: Comment preview URLs on PR + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (steps.deploy_docs.outputs.url != '' || steps.deploy_example.outputs.url != '') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCS_URL: ${{ steps.deploy_docs.outputs.url }} + EXAMPLE_URL: ${{ steps.deploy_example.outputs.url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + marker="" + body="${marker} + ### 🔥 Firebase Hosting previews + + | Target | Preview | + | --- | --- |" + if [ -n "${DOCS_URL}" ]; then body="${body} + | Docs | ${DOCS_URL} |"; fi + if [ -n "${EXAMPLE_URL}" ]; then body="${body} + | Example | ${EXAMPLE_URL} |"; fi + body="${body} + + _Channels expire 7 days after their last deploy._" + cids="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id")" + cid="$(head -1 <<<"${cids}")" + if [ -n "${cid}" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${cid}" -f body="${body}" >/dev/null + else + gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="${body}" >/dev/null + fi + + - name: Note for forked PRs + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + run: | + echo "This PR comes from a fork; secrets are not available, so Firebase preview deploy is skipped." diff --git a/.github/workflows/flutter_package.yaml b/.github/workflows/flutter_package.yaml index 086fc9bf..fbd08c8d 100644 --- a/.github/workflows/flutter_package.yaml +++ b/.github/workflows/flutter_package.yaml @@ -67,15 +67,62 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + # Resolve cache coordinates without installing -- see dart_package.yaml + # / flutter_base's identical fix: flutter-action's OWN `cache: true` + # step is a long-standing, still-open upstream bug on macOS/Windows + # runners (subosito/flutter-action#350) where its POST (save) phase + # cannot re-resolve its own `path` input and silently never persists + # the SDK. + - name: 🐦 Resolve Flutter SDK version + cache coordinates + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{inputs.flutter_version}} + channel: ${{inputs.flutter_channel}} + cache: false + pub-cache: "false" + dry-run: true + + # `steps.flutter_sdk_info.outputs.*` cannot be referenced directly in + # the cache step's `with:` below: its POST (save) phase fails to + # re-resolve a `steps.*.outputs.*`-based expression on macOS/Windows + # runners (confirmed live on this repo, run 37532075225 -- see + # flutter_base's identical step for the full story). Copying the two + # values into env vars first sidesteps it: `env.*` is a flat + # key/value context that replays correctly in POST on every OS. + - name: 🐦 Export Flutter SDK cache coordinates + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: 🐦 Restore Flutter SDK cache + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} - name: 🐦 Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{inputs.flutter_version}} channel: ${{inputs.flutter_channel}} - cache: true - cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + cache: false + pub-cache: "false" + + # Save only on pushes to main -- see flutter_base's identical step + # for the full rationale (PR/dispatch runs restore but never write + # their own branch-scoped copy of this multi-GB entry). + - name: 🐦 Save Flutter SDK cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} - name: 📦 Install Dependencies run: | flutter pub global activate very_good_cli @@ -101,12 +148,12 @@ jobs: run: ${{inputs.custom_tests}} - name: Report to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} if: ${{ inputs.run_tests }} with: - file: ${{inputs.working_directory}}/coverage/lcov.info + files: ${{inputs.working_directory}}/coverage/lcov.info fail_ci_if_error: false # - name: 📊 Check Code Coverage # uses: VeryGoodOpenSource/very_good_coverage@v2 diff --git a/.github/workflows/pana.yaml b/.github/workflows/pana.yaml index 4942f2df..ab018d31 100644 --- a/.github/workflows/pana.yaml +++ b/.github/workflows/pana.yaml @@ -30,13 +30,63 @@ jobs: steps: - name: 📚 Git Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + # Resolve cache coordinates without installing -- see + # .github/actions/flutter_base's comment: flutter-action's OWN + # `cache: true` step is a long-standing, still-open upstream bug on + # macOS/Windows runners (subosito/flutter-action#350) where its POST + # (save) phase cannot re-resolve its own `path` input and silently + # never persists the SDK; `pub-cache: "false"` independently avoids + # its internal pub-cache step's unscoped `hashFiles('**/pubspec.lock')` + # (observed failing -- "couldn't finish within 120 seconds" -- on a + # macOS runner elsewhere in this repo's CI). + - name: 🐦 Resolve Flutter SDK version + cache coordinates + id: flutter_sdk_info + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: false + pub-cache: "false" + dry-run: true + + # `steps.flutter_sdk_info.outputs.*` cannot be referenced directly in + # the cache step's `with:` below: its POST (save) phase fails to + # re-resolve a `steps.*.outputs.*`-based expression on macOS/Windows + # runners (confirmed live on this repo, run 37532075225 -- see + # flutter_base's identical step for the full story). Copying the two + # values into env vars first sidesteps it: `env.*` is a flat + # key/value context that replays correctly in POST on every OS. + - name: 🐦 Export Flutter SDK cache coordinates + shell: bash + run: | + set -eu + echo "FLUTTER_SDK_CACHE_PATH=${{ steps.flutter_sdk_info.outputs.CACHE-PATH }}" >> "$GITHUB_ENV" + echo "FLUTTER_SDK_CACHE_KEY=${{ steps.flutter_sdk_info.outputs.CACHE-KEY }}" >> "$GITHUB_ENV" + + - name: 🐦 Restore Flutter SDK cache + id: flutter_sdk_cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} - name: 🐦 Setup Flutter uses: subosito/flutter-action@v2 with: channel: stable - cache: true + cache: false + pub-cache: "false" + + # Save only on pushes to main -- see flutter_base's identical step + # for the full rationale (PR/dispatch runs restore but never write + # their own branch-scoped copy of this multi-GB entry). + - name: 🐦 Save Flutter SDK cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.flutter_sdk_cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: ${{ env.FLUTTER_SDK_CACHE_PATH }} + key: ${{ env.FLUTTER_SDK_CACHE_KEY }} # see https://github.com/invertase/melos/issues/796 - uses: bluefireteam/melos-action@main continue-on-error: true diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 29220fee..a649fa0e 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -1,7 +1,14 @@ name: Prepare release on: push: - branches: [main] + branches: [main, "release/**"] + +# Serialize runs so two Prepare-release jobs never race each other while +# opening/closing the release PR (otherwise each could close the other's +# freshly-created PR and leave zero, or two, PRs open). +concurrency: + group: release-prepare-${{ github.ref }} + cancel-in-progress: false jobs: prepare-release: @@ -12,7 +19,7 @@ jobs: runs-on: ubuntu-latest if: ${{ !contains(github.event.head_commit.message, 'chore(release)') }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: subosito/flutter-action@v2 @@ -20,8 +27,8 @@ jobs: - uses: bluefireteam/melos-action@main continue-on-error: true with: - run-bootstrap: true - bootstrap-no-example: false + run-bootstrap: true + bootstrap-no-example: false - uses: bluefireteam/melos-action@main with: run-bootstrap: true @@ -31,4 +38,66 @@ jobs: run-bootstrap: false run-versioning: true publish-dry-run: true - create-pr: true \ No newline at end of file + create-pr: true + # melos-action's own "Close previous release Pull Requests" step + # (which runs by default when create-pr is true) matches ANY open + # PR whose head branch looks like release-, with no check + # on PR title or base branch. On a single `main`-only trigger that + # was harmless (there was only ever one lineage of release-* PRs), + # but now that release/** branches push here too, an unfiltered + # run on e.g. release/oidc_ios-0.8.x would delete-branch-and-close + # a perfectly good, still-open release PR targeting main (or vice + # versa), since run ids (and thus head branch names) are global, + # not per-branch. Disable it and rely solely on the "Close + # superseded release PRs" step below, which we've made base-aware. + close-existing-pr: false + # melos-action (peter-evans/create-pull-request under the hood) always + # opens a *new* PR from a run-id-suffixed branch (release-), it + # never reuses/updates a previous one. Without this step every push to + # main that touches package versions piles up another open release PR. + # melos-action doesn't expose the PR number/branch as an action output, + # so the keeper is derived as "the newest open release-* PR". + # + # peter-evans/create-pull-request has no `base` input set above, so it + # defaults to "the branch checked out in the workflow" -- i.e. whatever + # branch (main or a release/** maintenance branch) triggered this push. + # melos-action has no base-branch input to set either way. That means + # the PR this step's sibling creates already targets the right branch; + # the only thing this step must additionally guard against is treating + # a release PR against a DIFFERENT base (e.g. main vs. + # release/oidc_ios-0.8.x) as superseding one another, since both use + # the same "chore(release): Publish packages" title and the same + # release- head-branch naming scheme. + - name: Close superseded release PRs + if: always() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Must match melos-action's `pr-title` default (not overridden above). + RELEASE_PR_TITLE: "chore(release): Publish packages" + CURRENT_BASE: ${{ github.ref_name }} + run: | + set -euo pipefail + + mapfile -t PR_NUMBERS < <( + gh pr list \ + --repo "${{ github.repository }}" \ + --state open \ + --json number,headRefName,title,baseRefName \ + --jq '.[] | select(.title == env.RELEASE_PR_TITLE and (.headRefName | startswith("release-")) and .baseRefName == env.CURRENT_BASE) | .number' \ + | sort -rn + ) + + if [ "${#PR_NUMBERS[@]}" -le 1 ]; then + echo "No superseded release PRs to close (found ${#PR_NUMBERS[@]})." + exit 0 + fi + + keeper="${PR_NUMBERS[0]}" + echo "Keeping newest release PR #$keeper" + + for number in "${PR_NUMBERS[@]:1}"; do + echo "Closing superseded release PR #$number" + gh pr comment "$number" --repo "${{ github.repository }}" \ + --body "Superseded by the latest Prepare release run." + gh pr close "$number" --repo "${{ github.repository }}" + done diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 82e8a526..2672303d 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -14,7 +14,7 @@ jobs: id-token: write # Required for authentication using OIDC runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: subosito/flutter-action@v2 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index d690c203..6ac2228b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1,7 +1,7 @@ name: Tag and start release on: push: - branches: [main] + branches: [main, "release/**"] jobs: publish-packages: @@ -12,7 +12,7 @@ jobs: runs-on: [ ubuntu-latest ] if: contains(github.event.head_commit.message, 'chore(release)') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: subosito/flutter-action@v2 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b920a1f5..8c4accfe 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -15,32 +15,99 @@ on: paths-ignore: - "**.md" workflow_dispatch: + inputs: + ios_heavy_diagnostics: + description: "iOS job: stream simulator logs and record the screen (adds load)" + type: boolean + default: false + ios_force_stall_module: + description: "iOS job, test-only: conformance module whose first attempt is forced to stall, to prove the gated rerun (empty = off)" + type: string + default: "" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + TEST_PRESET: all + CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + # jose_plus's crypto-executor suites (keygen/sign/encrypt) take ~55min + # per browser under dart2js's ~200x slower BigInt (#371); while they + # run, other suites' loads starve. Exclude them (tagged slow-web-crypto, + # see packages/jose_plus/dart_test.yaml) from PR web runs only -- VM + # runs always execute every tag, and main-branch pushes keep full web + # crypto signal. Hoisted to workflow level (was per-job on the old single + # unit_tests job) so every split job below inherits it without repeating + # it; the expression only reads the `github` context, which top-level + # workflow `env:` is documented to support. + WEB_EXCLUDE_TAGS: ${{ github.event_name == 'pull_request' && 'slow-web-crypto' || '' }} + defaults: run: shell: bash -leo pipefail {0} +# --------------------------------------------------------------------------- +# unit_tests speed-up (2026): this used to be ONE job per sdk channel running +# every step below SEQUENTIALLY (format -> analyze -> VM -> Chrome -> Firefox +# -> jose Chrome -> jose Firefox -> oidc_web_core coverage -> oidc_web Chrome +# -> Flutter -> coverage combine), making it the critical path of the whole +# workflow on main pushes (~41m stable / ~35m beta, measured on run +# 37423292012 -- the native integration jobs below are already <=25m after +# #492's sharding). Split into independently-schedulable jobs so the +# INDEPENDENT suites (VM, Chrome, Firefox, jose Chrome shards, jose Firefox +# shards) run CONCURRENTLY on the same ubuntu-latest pool the old single job +# used -- no macOS-pool-style concurrency cap applies here (see the +# ios/macos job comments further down for that constraint; it does not +# apply to this workflow's Linux-only unit-test jobs). +# +# Coverage plumbing: each coverage-producing job (unit_core's VM leg, +# unit_flutter, unit_webcore_coverage) flattens its own packages' +# coverage/lcov.info files into coverage/flat/.info -- the same +# flatten-before-upload pattern the android/ios/linux/windows jobs already +# use for their *-coverage.info artifacts below, which sidesteps +# actions/upload-artifact's common-ancestor path stripping for a glob like +# packages/*/coverage/lcov.info -- and uploads them under a job-specific +# artifact name. unit_tests_coverage downloads every such artifact and lets +# combine_coverage (which recursively discovers *.info files and rewrites +# their embedded absolute SF: paths via --repo-path -- the same mechanism +# the upload-coverage job below already relies on for the native coverage +# artifacts) merge them, re-emitting the exact "package-coverage" artifact +# the old single job produced. upload-coverage below needs no changes to +# its own steps, only to its `needs:`/`if:` (every job this split +# introduces must be accounted for there, same as `unit_tests` was before). +# --------------------------------------------------------------------------- jobs: - unit_tests: + # Format + analyze (stable only, unchanged) + VM tests (both channels) + + # VM coverage formatting (stable only). The "analyze+VM" leg from the + # design doc: Set up 31s + Analyze 63s + Test VM 188s in the baseline. + # timeout-minutes (job level): measured max ~7m16s end-to-end (setup + + # format + analyze + VM + coverage format/upload, across several runs on + # both channels). 25m is ~3.4x that, with headroom above the VM step's + # own 12m bound for setup/format/analyze. Added after run 37486189186's + # unit_core (beta) job hung 85+ minutes in "Test Dart packages [VM]" with + # NO bound at all (GitHub's default job timeout is 360 minutes) until a + # human cancelled it -- every package had already printed its own + # "N tests passed" summary (oidc_core's, the last one, at the 4-minute + # mark), so the hang was melos's exec wrapper never returning after an + # orphaned `dart:test.dart-` process from a mid-run "Connection reset by + # peer" on x509_plus's VM-service websocket. See the VM step below for + # the matching per-step bound. + unit_core: runs-on: ubuntu-latest + timeout-minutes: 25 strategy: fail-fast: false matrix: - sdk: [min, stable, beta] - env: - TEST_PRESET: all + sdk: [stable, beta] steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/flutter_base with: - channel: ${{ matrix.sdk == 'min' && 'stable' || matrix.sdk }} - flutter-version: ${{ matrix.sdk == 'min' && '3.22.0' || '' }} + channel: ${{ matrix.sdk }} + flutter-version: "" - run: | echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV - name: "[Verify step] ✨ Format" @@ -49,27 +116,590 @@ jobs: - name: "[Verify step] 🕵️ Analyze packages" if: ${{ matrix.sdk == 'stable' }} run: melos run analyze - # - name: '[Verify step] 🕵️ Run Pana' - # if: ${{ matrix.sdk == 'stable' }} - # run: | - # flutter pub global activate pana - # melos run pana + # timeout -k 10 600 bounds the hang above (an orphaned `dart test` + # kept the step alive after every package had printed its summary). + # Without --foreground, GNU timeout runs melos in its own process + # group and signals the whole group (TERM at 600s, KILL 10s later), + # which reaches the per-package `dart test` children; --foreground + # would signal melos alone. Normal runs take well under 4 minutes. + # The step timeout-minutes is a backstop. - name: "[Verify step] Test Dart packages [VM]" - run: melos run test:vm + timeout-minutes: 12 + run: timeout -k 10 600 melos run test:vm + - name: "[Coverage] Format VM coverage for each package" + if: ${{ matrix.sdk == 'stable' }} + run: | + set -e + dart pub global activate coverage + melos run coverage:format:package + - name: "[Coverage] Flatten VM coverage for upload" + if: ${{ matrix.sdk == 'stable' }} + run: | + set -e + mkdir -p coverage/flat + shopt -s nullglob + for f in packages/*/coverage/lcov.info; do + pkg="$(basename "$(dirname "$(dirname "$f")")")" + cp "$f" "coverage/flat/${pkg}.info" + done + ls -la coverage/flat + - name: Upload VM coverage + if: ${{ matrix.sdk == 'stable' }} + uses: actions/upload-artifact@v7 + with: + name: unit-coverage-vm-stable + path: coverage/flat/*.info + if-no-files-found: error + + # Dart Web tests in Chrome, split out of the old single job so it runs + # concurrently with Firefox/VM/jose instead of after them. PRs keep the + # package-scoped PR-scope retry loop (runs `dart test` directly, NOT via + # melos -- see the step below, unchanged rationale from the old job: keep + # this command literally in sync with test:web:pr:single in pubspec.yaml). + # Main pushes run the full multi-package sweep via melos. No coverage is + # collected on this leg in either scope -- unchanged from the original job + # (see test:web:single's comment on #372 for why browser coverage is + # collected separately, per-file, for oidc_web_core only). + # timeout-minutes (job level): a pure backstop above this job's OWN + # step-level bounds (18m PR-scope, 150m push-scope full sweep -- see + # below), sized at step-max + setup/checkout overhead so it never fires + # ahead of those; it only protects against something ELSE hanging (e.g. + # checkout or melos bootstrap) with no bound of its own. Measured actual + # durations are far smaller (~2-4m PR-scope, ~4-7m push-scope). + unit_chrome: + runs-on: ubuntu-latest + timeout-minutes: 160 + strategy: + fail-fast: false + matrix: + sdk: [stable, beta] + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: ${{ matrix.sdk }} + flutter-version: "" + - run: | + echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV + # set +e / retry loop: the #372 hang strikes this step at random + # (roughly half of executions on bad days); an honest run takes + # 20-90s. Each try is bounded at 5 minutes; three tries turn manual + # whole-job rerun roulette into an automatic in-step retry, while a + # REAL test failure still fails fast three times and blocks the PR. + - name: "[Verify step] Test Dart packages [Chrome, PR scope]" + if: ${{ github.event_name == 'pull_request' }} + timeout-minutes: 18 + working-directory: packages/oidc_web_core + run: | + set +e + for attempt in 1 2 3; do + timeout --foreground -k 10 300 dart test --suite-load-timeout 4m --concurrency=1 --platform chrome --chain-stack-traces + rc=$? + if [ "$rc" -eq 0 ]; then exit 0; fi + echo "chrome PR-scope attempt $attempt exited rc=$rc; retrying" || true + done + exit 1 + # Push scope: full multi-package sweep (all web-tested Dart packages + # except jose_plus/oidc_cli -- see test:web:single's packageFilters). + # timeout-minutes is the backstop of last resort -- a browser wedge + # can strike outside package:test's own timeouts (run 29148902320: a + # Firefox step hung 57+ min with the 4m load bound active); the + # runner kills the whole process tree so the step fails loudly and is + # rerunnable instead of idling to GitHub's 6h job kill. - name: "[Verify step] Test Dart packages [Chrome]" + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 150 run: melos run test:web:chrome + + # Dart Web tests in Firefox -- mirrors unit_chrome. The PR-scope Firefox + # step keeps continue-on-error: true (unchanged): forensics on the #372 + # Firefox freeze (run 29162814103) showed it strikes mid-suite with even + # the in-browser per-test timer frozen, and the identical step passes in + # ~57s when the freeze doesn't strike. Chrome stays blocking; full Firefox + # signal still runs on every main push via the push-scope step below, + # which has NO continue-on-error (unchanged). + # timeout-minutes (job level): same reasoning as unit_chrome above -- a + # backstop above this job's own step-level bounds (18m PR-scope, 150m + # push-scope), not the primary gate. + unit_firefox: + runs-on: ubuntu-latest + timeout-minutes: 160 + strategy: + fail-fast: false + matrix: + sdk: [stable, beta] + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: ${{ matrix.sdk }} + flutter-version: "" + - run: | + echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV + - name: "[Verify step] Test Dart packages [Firefox, PR scope]" + if: ${{ github.event_name == 'pull_request' }} + timeout-minutes: 18 + continue-on-error: true + working-directory: packages/oidc_web_core + env: + MOZ_HEADLESS: "1" + run: | + set +e + for attempt in 1 2 3; do + timeout --foreground -k 10 300 dart test --suite-load-timeout 4m --concurrency=1 --platform firefox --chain-stack-traces + rc=$? + if [ "$rc" -eq 0 ]; then exit 0; fi + echo "firefox PR-scope attempt $attempt exited rc=$rc; retrying" || true + done + exit 1 - name: "[Verify step] Test Dart packages [Firefox]" + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 150 run: melos run test:web:firefox + + # jose_plus per-file Chrome runs, sharded N=4 ways. jose's multi-suite + # `dart test` completes every suite then never exits on Firefox/Chrome + # (post-success exit hang, dart-lang/test#2294 class, #372); one file per + # process already sidesteps the hang (15/16 suites exit clean alone), and + # this additionally shards those per-file runs across 4 parallel jobs + # instead of one job looping through all 16 files serially (measured + # 560s/736s Chrome/Firefox on main -- ~4x wall-time cut per browser, + # modulo per-shard dart2js compile overhead). main-push only, matching the + # original job (PRs never ran jose_plus here at all -- its only PR-scope + # coverage is the VM leg in unit_core, which runs every tag regardless of + # event). + # Deterministic partition, computed independently inside each shard job + # (not via a separate manifest) from `ls test/*_test.dart | sort`: every + # shard recomputes the FULL partition and asserts (a) the full file list + # is non-empty, (b) every one of the 4 shards' slices is non-empty, and + # (c) the slices' sizes sum to the full count -- so a shard running 0 + # files, or a bug that drops a file from every shard, fails loudly instead + # of silently losing coverage. + unit_jose_chrome: + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + sdk: [stable, beta] + shard: [1, 2, 3, 4] + defaults: + run: + working-directory: packages/jose_plus + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: ${{ matrix.sdk }} + flutter-version: "" + - run: | + echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV + - name: "[Verify step] Test jose_plus [Chrome, shard ${{ matrix.shard }}/4]" + run: | + set -e + SHARDS=4 + MY_SHARD=${{ matrix.shard }} + mapfile -t ALL_FILES < <(ls test/*_test.dart | sort) + TOTAL=${#ALL_FILES[@]} + if [ "$TOTAL" -eq 0 ]; then + echo "::error::no jose_plus test/*_test.dart files found -- partition is vacuous" + exit 1 + fi + declare -A COUNTS + for ((i=0; i> $GITHUB_ENV + - name: "[Verify step] Test jose_plus [Firefox, shard ${{ matrix.shard }}/4]" + run: | + set -e + SHARDS=4 + MY_SHARD=${{ matrix.shard }} + mapfile -t ALL_FILES < <(ls test/*_test.dart | sort) + TOTAL=${#ALL_FILES[@]} + if [ "$TOTAL" -eq 0 ]; then + echo "::error::no jose_plus test/*_test.dart files found -- partition is vacuous" + exit 1 + fi + declare -A COUNTS + for ((i=0; i> $GITHUB_ENV + - name: "[Coverage] oidc_web_core browser coverage (per-file, hang-safe)" + working-directory: packages/oidc_web_core + run: | + set -e + rm -rf coverage/chrome + for f in test/*_test.dart; do + dart test "$f" --suite-load-timeout 4m --platform chrome --coverage=coverage/chrome --chain-stack-traces + done + - name: "[Coverage] Format oidc_web_core coverage" + run: | + set -e + dart pub global activate coverage + melos run coverage:format:package + - name: "[Coverage] Flatten oidc_web_core coverage for upload" + run: | + set -e + mkdir -p coverage/flat + cp packages/oidc_web_core/coverage/lcov.info coverage/flat/oidc_web_core.info + ls -la coverage/flat + - name: Upload oidc_web_core coverage + uses: actions/upload-artifact@v7 + with: + name: unit-coverage-webcore-stable + path: coverage/flat/*.info + if-no-files-found: error + + # oidc_web (#370): the only Flutter *plugin* package with browser-only lib + # code. Runs on both PR and push (unchanged); no coverage collected here + # (the original step has no --coverage flag either). + # timeout-minutes (job level): backstop above the 10m step-level bound + # already on the test step below (setup overhead ~2-3m). + unit_oidc_web_chrome: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + sdk: [stable, beta] + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: ${{ matrix.sdk }} + flutter-version: "" + - run: | + echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV + - name: "[Verify step] Test oidc_web [Chrome]" + timeout-minutes: 10 + run: melos run test:oidc_web:chrome + + # Flutter-package unit tests + the example's own unit tests (the latter is + # test signal only, not part of CI's coverage upload -- unchanged from the + # original job). `flutter test --coverage` writes lcov.info directly, no + # format_coverage step needed (unchanged). + # timeout-minutes (job level): this job had NO timeout at all (step or + # job) before this change. Measured max ~2m08s end-to-end across + # several runs; 10m is ~5x that -- generous because `flutter test` on + # the example app is the one step here that hasn't been individually + # measured in isolation. + unit_flutter: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + sdk: [stable, beta] + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: ${{ matrix.sdk }} + flutter-version: "" + - run: | + echo TARGET_DART_SDK=${{ matrix.sdk }} >> $GITHUB_ENV - name: Remove dart_test.yaml Files run: melos run remove_dart_test_yaml - name: "[Verify step] Test Flutter packages" run: melos run test:flutter - - name: "[Coverage] Format & print test coverage" + - name: "[Verify step] Test the example's unit tests" + # melos `test:flutter` filters `ignore: ["*example*"]` (pubspec.yaml), + # and the only other thing CI runs out of packages/oidc/example is + # `flutter test integration_test/...` in the platform jobs. So every + # file under packages/oidc/example/test/ ran in NO job: the + # conformance variant rules, the plan gates, the flow-timeout budget + # and the patrol tally reconciler all passed locally and gated + # nothing. They are the guards for a harness that drives a live + # third-party server, which is exactly the code that cannot be + # re-verified cheaply after the fact. + working-directory: packages/oidc/example + run: flutter test + - name: "[Coverage] Flatten Flutter package coverage for upload" if: ${{ matrix.sdk == 'stable' }} - run: melos run coverage:combine - - name: Upload Unit Test Coverage Report - uses: actions/upload-artifact@v4 + run: | + set -e + mkdir -p coverage/flat + shopt -s nullglob + for f in packages/*/coverage/lcov.info; do + pkg="$(basename "$(dirname "$(dirname "$f")")")" + cp "$f" "coverage/flat/${pkg}.info" + done + ls -la coverage/flat + - name: Upload Flutter coverage if: ${{ matrix.sdk == 'stable' }} + uses: actions/upload-artifact@v7 + with: + name: unit-coverage-flutter-stable + path: coverage/flat/*.info + if-no-files-found: error + + # Merges every coverage-producing unit job above (unit_core's VM leg, + # unit_flutter, unit_webcore_coverage) back into the single + # "package-coverage" artifact the old single job produced, so + # upload-coverage below is unchanged. unit_webcore_coverage is skipped + # entirely on PRs (see its own `if:`), so this job tolerates that specific + # dependency being 'skipped' while still requiring unit_core/unit_flutter + # to have actually succeeded -- otherwise a real failure upstream would be + # masked by `always()`. + # timeout-minutes (job level): this job had NO timeout at all before this + # change. Measured max ~57s-1m end-to-end across several runs; 10m is a + # generous margin given the artifact download and dart pub global + # activate steps are network-dependent and not purely CPU-bound like the + # measured samples. + unit_tests_coverage: + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [unit_core, unit_flutter, unit_webcore_coverage] + if: | + always() && + needs.unit_core.result == 'success' && + needs.unit_flutter.result == 'success' && + (needs.unit_webcore_coverage.result == 'success' || needs.unit_webcore_coverage.result == 'skipped') + steps: + - name: Checkout Code + uses: actions/checkout@v7 + - name: Set up Environment + uses: ./.github/actions/flutter_base + with: + channel: stable + flutter-version: "" + - name: Download unit coverage shards + uses: actions/download-artifact@v8 + with: + path: coverage/flat-merged/ + pattern: "unit-coverage-*-stable" + merge-multiple: true + # Restores each flattened packages//coverage/lcov.info back to its + # NATURAL on-disk location before merging. Confirmed live (run + # 37464828887) that skipping this and running combine_coverage + # straight over the flat files breaks the Flutter packages: combine_ + # coverage resolves each file's SF: entries relative to the directory + # the file is found in whenever those entries are already + # package-relative (not absolute) -- which is exactly how `flutter + # test --coverage` writes them (e.g. "SF:lib/facade.dart", no + # "packages//" prefix, unlike the VM/format_coverage-generated + # files, whose SF: entries are already absolute and therefore survive + # being read from anywhere). Flat in coverage/flat-merged/, the four + # Flutter packages' guard matched 0 ("SF:/coverage/lib/src/facade.dart" + # -- repo-path + "/coverage" + the relative path, with the + # "packages//" segment lost entirely). Rebuilding + # packages//coverage/lcov.info here makes combine_coverage see + # the exact directory layout it always saw in the old single job, for + # both path styles. + # + # `mv`, not `cp`, and an explicit `rm -rf` of the whole download + # directory afterward: independent review on PR #494 (run + # 37476666335) caught that a `cp` here left the original flat files + # sitting in coverage/flat-merged/ too, so combine_coverage's + # recursive scan ingested BOTH the restored packages//coverage/ + # copy AND the stale flat one -- the flat one resolves to the same + # bogus "/coverage/" phantom SF: entries described + # above, since it's no longer sitting in a per-package directory. + # That run's package-coverage artifact had 254 SF: lines for 139 + # unique files (115 duplicates) and 11 phantom /coverage/... paths. + - name: "[Coverage] Restore package-relative coverage layout" + run: | + set -e + shopt -s nullglob + for f in coverage/flat-merged/*.info; do + pkg="$(basename "$f" .info)" + mkdir -p "packages/$pkg/coverage" + mv "$f" "packages/$pkg/coverage/lcov.info" + done + rm -rf coverage/flat-merged + find packages -maxdepth 3 -name lcov.info + - name: "[Coverage] Combine & convert coverage report" + env: + REPO_PATH: ${{ github.workspace }} + run: | + set -e + dart pub global activate combine_coverage + dart pub global activate remove_from_coverage + dart pub global run combine_coverage --repo-path="$REPO_PATH" + dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r 'oidc_cli/lib/src/version\.dart$' + # Structural guard added alongside the mv/rm fix above: catches this + # exact failure mode (and anything similar) directly, instead of + # relying only on the package-specific guards below, which can pass + # even with duplicate/phantom entries polluting the file (as they did + # on run 37476666335 -- the Flutter-package guard below matched fine + # because the CORRECT entries were also present, just alongside 115 + # duplicates it never looked for). + - name: "[Coverage] Assert no duplicate or phantom SF entries" + run: | + set -e + total=$(grep -c '^SF:' coverage/lcov.info || true) + total=${total:-0} + unique=$(grep '^SF:' coverage/lcov.info | sort -u | wc -l | tr -d ' ') + echo "guard: SF total=$total unique=$unique" + if [ "$total" -ne "$unique" ]; then + echo "::error::coverage/lcov.info has $((total - unique)) duplicate SF: line(s) out of $total - a source file's coverage was recorded more than once (a stale intermediate coverage artifact leaking into the merge?)" + echo "guard: duplicated SF: entries:" + grep '^SF:' coverage/lcov.info | sort | uniq -c | awk '$1>1 {print}' | head -20 + exit 1 + fi + missing=0 + while IFS= read -r sf; do + p="${sf#SF:}" + p="${p#/}" + if [ -z "$p" ] || [ ! -f "$p" ]; then + echo "::error::coverage/lcov.info references a path that doesn't exist in this checkout: $sf" + missing=1 + fi + done < <(grep '^SF:' coverage/lcov.info | sort -u) + if [ "$missing" -ne 0 ]; then + exit 1 + fi + echo "guard: all $total SF: entries are unique and point to real files in the checkout" + - name: "[Coverage] Assert Flutter package coverage survived" + # Regression guard, unchanged from the original job. `flutter test + # --coverage` writes coverage/lcov.info directly; running + # format_coverage over that directory finds no VM hitmap .json, + # exits 0, and truncates the file to 0 bytes. That silently dropped + # every Flutter package's unit coverage from the upload with no + # failing step. Fail loudly if it ever comes back. + run: | + wc -c coverage/lcov.info || true + echo "guard: first 10 SF entries:" + grep -o -m 10 'SF:[^[:cntrl:]]*' coverage/lcov.info || true + missing=0 + for p in oidc oidc_default_store oidc_platform_interface oidc_desktop; do + n=$(grep -c "packages/$p/lib/" coverage/lcov.info || true) + n=${n:-0} + echo "guard: packages/$p/lib/ -> $n match(es)" + if [ "$n" -eq 0 ]; then + echo "::error::no unit coverage recorded for packages/$p - Flutter coverage is being discarded again" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + echo "guard: all four Flutter packages present in the combined lcov" + - name: "[Coverage] Assert oidc_web_core browser coverage survived" + # main + stable only, unchanged from the original job. + if: ${{ github.event_name != 'pull_request' }} + run: | + n=$(grep -c "packages/oidc_web_core/lib/" coverage/lcov.info || true) + n=${n:-0} + echo "guard: packages/oidc_web_core/lib/ -> $n match(es)" + if [ "$n" -eq 0 ]; then + echo "::error::no browser coverage for oidc_web_core - the per-file oidc_web_core coverage step is being discarded" + exit 1 + fi + echo "guard: oidc_web_core browser coverage present in the combined lcov" + - name: Upload Unit Test Coverage Report + uses: actions/upload-artifact@v7 with: name: package-coverage path: coverage/lcov.info @@ -77,55 +707,271 @@ jobs: # integration tests android: runs-on: ubuntu-latest + # Sharded 4 ways (not 2, like ios -- see that job's comment): this job + # runs on ubuntu-latest, drawing from GitHub's large general-purpose + # concurrent-job pool (20-60 depending on plan), not the much smaller + # 5-job *concurrent macOS jobs* cap that constrains ios/macos, so there's + # no cross-job concurrency budget to conserve here. CONFORMANCE_SHARD, + # read by patrol_test/app_test.dart, splits the 13 conformance plans into + # 4 parallel matrix legs instead of one job running them all sequentially. + # The AVD cache key below is intentionally NOT shard-scoped: the snapshot + # itself doesn't depend on which plans a leg runs, so every shard shares + # it (concurrent cache restores are fine; a cold-cache run may have more + # than one shard attempt to save the same key, which is a harmless no-op + # for whichever shard loses that race). strategy: + fail-fast: false matrix: - api-level: [29] + shard: [basic, implicit, hybrid, rest] + api-level: [33] + arch: [x86_64] + # google_apis, not `default`: the AOSP image ships no browser, so the + # authorization URL cannot open. Not google_apis_playstore either; its + # locked-down adb does not accept the runner's key under this job's + # relocated ANDROID_SDK_HOME, and the AVD never finishes booting. + target: [google_apis] defaults: run: working-directory: packages/oidc/example steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base + - name: Free disk space + run: | + set -euxo pipefail + df -h + # Keep Android SDK (we need it). Remove other heavy toolchains. + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL || true + sudo docker image prune --all --force || true + sudo docker builder prune -a --force || true + df -h - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Configure Android AVD home + run: | + # GitHub hosted runners have more free space under /mnt than $HOME. + # This prevents "Not enough space to create userdata partition" errors. + echo "ANDROID_SDK_HOME=/mnt/.android" >> $GITHUB_ENV + echo "ANDROID_AVD_HOME=/mnt/.android/avd" >> $GITHUB_ENV + sudo mkdir -p /mnt/.android/avd + sudo chown -R "$USER":"$USER" /mnt/.android + + # gradle/actions/setup-gradle's own `cache-read-only` default is + # `${{ github.ref_name != github.event.repository.default_branch }}` + # -- i.e. already read-only on every PR/branch run, write-enabled + # only on pushes to main. That default doesn't account for this job + # being matrix-sharded 4 ways (basic/implicit/hybrid/rest): on a main + # push, all 4 shards run concurrently and ALL get write-enabled by + # that default, independently populating the (mostly OS/project- + # keyed, not shard-keyed) gradle-dependencies-v2/gradle-transforms-v2 + # entries. Confirmed live via `gh api repos/Bdaya-Dev/oidc/actions/ + # caches`: two sibling entries each for gradle-dependencies-v2 + # (1.13GB + 0.67GB) and gradle-transforms-v2 (1.31GB + 0.47GB) -- + # different shards' concurrent first-save races producing + # non-identical content for what should be one shared entry, instead + # of the normal harmless "first writer wins" no-op. Explicitly + # layering a single-writer rule on top (keeping the upstream default + # untouched for every non-default-branch case, where it's already + # correct) fixes this: only the `basic` shard gets write access on + # main; `implicit`/`hybrid`/`rest` are forced read-only there too, so + # exactly one shard ever saves. - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref_name != github.event.repository.default_branch || matrix.shard != 'basic' }} - name: Run flutter precache run: flutter precache -v + # Native (Kotlin/Robolectric) tests for the oidc_android plugin, covering + # the redirect-capture path the Dart suite cannot reach: it mocks the + # Pigeon channel, so OidcPlugin.kt never executes there. JVM-only, so it + # needs no emulator and fails fast, ahead of one. + - name: "[Verify step] Native Android unit tests (oidc_android)" + # gradlew / gradle-wrapper.jar are gitignored repo-wide (only + # gradle-wrapper.properties is tracked), so a fresh checkout has no + # wrapper to invoke. --config-only generates the android build files + # without building any artifact. + run: | + flutter build apk --config-only + cd android + ./gradlew :oidc_android:testDebugUnitTest -PoidcNativeTests - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@v6 id: avd-cache with: path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} + /mnt/.android/avd/* + /mnt/.android/adb* + # include arch + target to avoid reusing old 32-bit / browserless images + key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }}-novulkan - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false + arch: ${{ matrix.arch }} + target: ${{ matrix.target }} + force-avd-creation: true + # For snapshot caching, do NOT use -no-snapshot. + emulator-options: -no-window -gpu swiftshader_indirect -no-audio -no-boot-anim -camera-back none + disable-animations: true + emulator-boot-timeout: 900 script: echo "Generated AVD snapshot for caching." - name: Run flutter precache run: flutter precache -v - - name: Run flutter doctor - run: flutter doctor -v + - name: Activate Patrol CLI (patrol_cli_plus) + run: dart pub global activate patrol_cli_plus + - name: Prepare Android integration script + run: | + cat <<'EOF' > android_emulator_ci.sh + #!/usr/bin/env bash + set -eo pipefail + + # patrol_cli_plus installs the `patrol_plus` executable into the pub + # cache bin; make sure it's resolvable inside the emulator-runner shell. + export PATH="$PATH:$HOME/.pub-cache/bin" + + echo "ADB devices before:" + adb devices -l || true + + # Wait for a device to be 'device' (boot completed) + adb wait-for-device + + # Pick the first Android device id + EMU_ID="$(adb devices | while read -r serial state _; do if [ "$state" = "device" ]; then echo "$serial"; break; fi; done)" + if [ -z "$EMU_ID" ]; then + echo "No Android device found. Available devices:" + flutter devices + exit 1 + fi + + echo "Using device: $EMU_ID" + flutter devices + + # The conformance leg needs a browser that lacks Auth Tab support to + # be meaningful, so record what this image actually ships. "No browser" + # and "browser present but redirect not captured" are different defects + # with different fixes. + echo "--- browser packages on the emulator (pre-install) ---" + adb -s "$EMU_ID" shell pm list packages | grep -iE 'chrome|browser|webview|firefox' || echo "NO browser/chrome/webview package matched" + echo "--- what resolves an https VIEW intent ---" + adb -s "$EMU_ID" shell 'cmd package resolve-activity --brief -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d https://example.com' || true + echo "--- end pre-install diagnostics ---" + + # A first-run device shows Chrome's welcome/ToS gate, and a Custom Tab + # opened behind it never reaches the auth URL, so no redirect returns. + # + # /data/local/tmp, not /data/local: the latter is SELinux-blocked. The + # first token is a placeholder because Chrome parses the file as an argv + # line and discards element 0. + # Ref: chromium.org/developers/how-tos/run-chromium-with-flags + echo "--- suppressing Chrome first-run ---" + adb -s "$EMU_ID" root >/dev/null 2>&1 || true + adb -s "$EMU_ID" shell 'echo "_ --no-first-run --disable-fre --no-default-browser-check" > /data/local/tmp/chrome-command-line' || true + adb -s "$EMU_ID" shell 'am set-debug-app --persistent com.android.chrome' || true + adb -s "$EMU_ID" shell 'cat /data/local/tmp/chrome-command-line' || true + echo "--- end Chrome first-run suppression ---" + # Runs every patrol_test/*_test.dart (conformance + e2e + offline) on + # the emulator and collects Dart coverage via the VM service. + # --verbose: patrol forwards NO test stdout without it, so neither the + # test names nor the "Total: N" summary reach the job log. + # `set -eo pipefail` is set above, so tee preserves patrol's exit code. + # Capture the status rather than letting `set -e` abort here: the + # diagnostics below matter most when the run failed. Honoured at the end. + set +e + patrol_plus test \ + --coverage \ + --coverage-ignore="**/*.g.dart" \ + --coverage-workspace \ + -d "$EMU_ID" \ + --verbose \ + --dart-define=CI=true \ + --dart-define=CONFORMANCE_SHARD=${CONFORMANCE_SHARD} \ + --dart-define=OIDC_CONFORMANCE_TOKEN=${OIDC_CONFORMANCE_TOKEN} \ + 2>&1 | tee patrol-android.log + PATROL_RC=${PIPESTATUS[0]} + set -e + echo "patrol exit status: $PATROL_RC" + + # POST-INSTALL, which is the only point this means anything: patrol is what + # builds and installs the APK, so probing before it always reports "No + # activity found" whether or not the manifest is wired. + # + # Direct check that the merged manifest registered OidcRedirectActivity + # for the app's redirect scheme. If it resolves, capture is wired and a + # remaining failure is browser or flow side; if not, the wiring is. + # Read the manifest from the APK patrol built, not from the device: + # patrol uninstalls the app when it finishes, so an adb probe afterwards + # always reports it missing. This reads the bytes that were installed. + echo "--- APK manifest: which scheme is OidcRedirectActivity registered for? ---" + APK="$(find build/app/outputs -name 'app-debug.apk' -print -quit 2>/dev/null || true)" + echo "apk: ${APK:-}" + if [ -n "$APK" ]; then + AAPT2="$(find "$ANDROID_SDK_ROOT/build-tools" "$ANDROID_HOME/build-tools" -name aapt2 -type f 2>/dev/null | sort | tail -1)" + echo "aapt2: ${AAPT2:-}" + if [ -n "$AAPT2" ]; then + "$AAPT2" dump xmltree --file AndroidManifest.xml "$APK" 2>/dev/null \ + | grep -A 12 'OidcRedirectActivity' | head -20 || echo "OidcRedirectActivity NOT in the APK manifest" + fi + fi + echo "--- end post-run diagnostics ---" + + # patrol_cli_plus exits 0 on a run that executed nothing, because its + # android backend only inspects gradle's exit code. Until a release + # carries the upstream guard, assert the count here so a green job + # cannot mean "no tests ran". + TOTAL="$(grep -oE 'Total:[[:space:]]*[0-9]+' patrol-android.log | tail -1 | grep -oE '[0-9]+' || true)" + echo "Android patrol executed ${TOTAL:-} test(s)." + if [ -z "$TOTAL" ]; then + echo "::error::patrol printed no 'Total:' line - cannot prove any test ran." + exit 1 + fi + if [ "$TOTAL" -lt 4 ]; then + echo "::error::patrol executed $TOTAL test(s), expected at least 4 (app_test + 3 offline_mode_test)." + exit 1 + fi + # patrol writes coverage/patrol_lcov.info; rename for the combine + # step. Shard-suffixed so the 4 matrix legs' coverage files don't + # collide once upload-coverage's merge-multiple download flattens + # every *-integration-coverage artifact into one directory. + cp coverage/patrol_lcov.info "coverage/android-${CONFORMANCE_SHARD}-coverage.info" + + # Deferred above so the diagnostics always run. The count ratchet is an + # additional gate, not a replacement: a run can execute every test and + # still fail one. + if [ "$PATROL_RC" -ne 0 ]; then + echo "::error::patrol reported failure (exit $PATROL_RC); see the test output above." + exit "$PATROL_RC" + fi + EOF + chmod +x android_emulator_ci.sh - name: Run Android Emulator and Integration Tests uses: reactivecircus/android-emulator-runner@v2 + env: + OIDC_CONFORMANCE_TOKEN: ${{ secrets.OIDC_CONFORMANCE_TOKEN }} + CONFORMANCE_SHARD: ${{ matrix.shard }} with: working-directory: packages/oidc/example api-level: ${{ matrix.api-level }} - script: melos run integration_test:android + arch: ${{ matrix.arch }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -no-audio -no-boot-anim -camera-back none + disable-animations: true + emulator-boot-timeout: 900 + target: ${{ matrix.target }} + script: ./android_emulator_ci.sh + # Diagnostics only, same reasoning as the iOS job: moved off the normal + # path and gated on failure so it no longer costs anything when the run + # is healthy. + - name: Run flutter doctor + if: failure() + run: flutter doctor -v - name: Find .info files and files in coverage folders run: | echo "Listing all .info files and files inside any coverage/ directory:" @@ -133,179 +979,853 @@ jobs: echo "$GITHUB_WORKSPACE" find "$GITHUB_WORKSPACE" \( -name "*.info" -o -path "*/coverage/*" \) -type f -exec ls -l {} + - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: android-integration-coverage - path: packages/oidc/example/coverage/android-coverage.info + name: android-${{ matrix.shard }}-integration-coverage + path: packages/oidc/example/coverage/android-${{ matrix.shard }}-coverage.info include-hidden-files: true if-no-files-found: error ios: - runs-on: macos-latest + # Gating iOS environment: macos-26 image, Xcode 26.6 (selected explicitly + # in the step below), iOS 26 simulator. Previously pinned to macos-15 + # (Xcode 16.4) because XCUITest's app relaunch between patrolTests failed + # on Xcode 26 ("Failed to terminate com.bdayadev:..." -> xcodebuild exit + # 65) even though every Dart test passed. That is now fixed in our patrol + # fork and shipped to pub.dev: + # - patrol_plus >= 5.6.1 suppresses the benign XCUITest app + # relaunch/terminate framework failures (Bdaya-Dev/patrol#15) so they + # no longer fail the run; a genuine Dart-test failure is unaffected. + # - patrol_cli_plus >= 5.4.1 defaults xcodebuild's -destination-timeout + # to 30s (was a hardcoded 1s), fixing the exit-70 device-resolution + # race on the slow macos-26 simulator. + # Validated green across multiple full-suite runs with the flake firing + # and being suppressed each time; the macos-15 pin is lifted. Xcode 26.6 + # (not the image default 26.5) is selected because the XCUITest relaunch + # flake is markedly rarer on 26.6. + # + # Sharded, but only 2 ways, not 4 -- the 13 conformance plans used to run + # sequentially in one job (~1326s of the ~29min job on run 37346215172 -- + # Hybrid RP alone is ~262s). CONFORMANCE_SHARD (read by + # patrol_test/app_test.dart) splits them into 2 parallel matrix legs, + # "heavy" (Hybrid RP + Implicit RP, the two slowest plans, ~445s by the + # same run's per-plan timing) and "light" (Basic RP + everything else, + # ~364s), chosen to land close to half the total each. + # + # 2 ways, not 4: GitHub caps *concurrent macOS jobs* at 5 on Free/Pro/Team + # (shared across standard AND larger runners -- see + # https://docs.github.com/en/actions/reference/limits, "Usage limits"), + # separately from and far below the general concurrent-job limit (20-60). + # This job and `macos` are the only two macOS-pool consumers in this + # workflow. A first attempt sharded both 4 ways each (8 concurrent + # macOS-pool requests against a cap of 5): run 37398194429 measured iOS + # shards starting at 01:15:08/01:18:23/01:29:43/01:39:46 and finishing at + # 01:57:49 -- 42 minutes end-to-end, WORSE than the ~29min unsharded + # baseline, because 3 of the 4 shards queued behind the cap instead of + # running in parallel. At 2 shards here + macos' now-single job (see that + # job's comment -- it reverted to unsharded), this workflow's own + # macOS-pool demand is 3, comfortably under 5. + # + # `needs: [... ios ...]` on upload-coverage already waits for every + # matrix leg and skips if any fails/cancels -- no separate aggregator job + # is needed for that. No branch-protection required status check + # currently names "ios" on this repo (checked via `gh api + # repos/Bdaya-Dev/oidc/branches/main/protection` -> 404 "Required status + # checks not enabled"), so the per-shard context names ("ios (heavy)" / + # "ios (light)" instead of a single "ios") are not a breaking change + # today; revisit if required checks are ever turned on for this branch. + # + # The simulator boot is kicked off in the background (`simctl boot &`, + # detached with `disown`) right AFTER "Set up Environment", so it overlaps + # only "Run flutter precache" and the Patrol CLI activation. It used to + # start before setup, and booting alongside the Flutter install made that + # install 3-4x slower (see the note on the boot step). `simctl + # bootstatus -b` right before the tests step is the + # actual gate: it boots the device if the background kick-off hasn't + # finished yet, and either way blocks until CoreSimulatorService reports + # the device as *actually* registered -- see the note on that step for + # why a bare `boot` is not enough on its own. + # + # #469: on this runner (macos-26: GitHub's standard arm64 runner, 3 vCPU / + # 7 GB RAM) the simulator's browser intermittently takes 30-150s to start, + # or never does: WebKit process launches stall while the host swaps. None + # of the following made the stalls go away, measured over 2+ iOS runs each: + # * Spotlight indexing off, other simulators shut down, smallest iPhone; + # * sharding (#492); + # * macos-26-intel (4 vCPU / 14 GB): setup and tests ran 2-3x slower and + # both shards hit the 60-minute timeout. + # So the harness reruns a module once, on a fresh instance, and only when + # the suite still reports that instance WAITING and its log shows no + # authorization request ever arriving (refused requests count as + # arrived). The gate is shouldRerunModuleOnFreshInstance in + # integration_test/conformance/api.dart. It applies on every platform; + # only this simulator's stalls have been seen to meet it. The + # CONFORMANCE_FORCE_STALL_MODULE dart-define (workflow_dispatch input + # ios_force_stall_module, empty by default) forces one such stall to + # prove the path end to end. + runs-on: macos-26 timeout-minutes: 60 + env: + # workflow_dispatch input; push/PR runs never pay for heavy diagnostics. + # See "Start iOS diagnostics". + IOS_HEAVY_DIAGNOSTICS: ${{ github.event_name == 'workflow_dispatch' && inputs.ios_heavy_diagnostics }} + # Test-only; empty (off) unless set by hand on a workflow_dispatch run. + IOS_FORCE_STALL_MODULE: ${{ github.event_name == 'workflow_dispatch' && inputs.ios_force_stall_module || '' }} + strategy: + fail-fast: false + matrix: + shard: [heavy, light] defaults: run: working-directory: packages/oidc/example steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 + - name: Select Xcode 26.6 + # The macos-26 image ships several Xcode versions side by side; its + # default is 26.5. The patrol#15 XCUITest terminate flake is markedly + # less frequent on 26.6, so select it explicitly when present. + run: | + if [ -d /Applications/Xcode_26.6.app ]; then + sudo xcode-select -s /Applications/Xcode_26.6.app + else + echo "::warning::Xcode 26.6 not found; available Xcodes:" + ls -d /Applications/Xcode_*.app || true + fi + xcodebuild -version - name: Set up Environment uses: ./.github/actions/integration_test_base - with: - channel: master - flutter-version: "ca758ac49b0e93798ce3fbe49e2c8ce290d5e136" - - name: Start Simulator - # Start an iPhone simulator - run: | - UDID=$(xcrun xctrace list devices | grep "^iPhone" | awk '{gsub(/[()]/,""); print $NF}' | head -n 1) - echo $UDID - xcrun simctl boot "${UDID:?No Simulator with this name found}" - - name: Wait for Simulator to be Ready + # with: + # channel: master + # flutter-version: "ca758ac49b0e93798ce3fbe49e2c8ce290d5e136" + - name: Start Simulator (boot in background) + # Kicks off the boot and returns immediately, so the next steps + # (flutter precache, Patrol CLI activation) run while the simulator + # boots, instead of waiting on it first. + # + # It deliberately starts AFTER "Set up Environment", not before. + # Booting alongside the Flutter install made that install 3-4x slower + # on the 3-vCPU runner: setup script 65s with a synchronous boot vs + # 178-273s concurrently, and cache-pub's hashFiles 0.5s vs 27-125s. + # That net-lost 80-240s per leg (main 6ada33a jobs 112244868347/381 vs + # c171634 job 112065979344). The steps it overlaps now are cheap. + # `nohup ... & disown` + # detaches the boot from this step's shell so it keeps running after + # the step exits -- the job's later "Wait for Simulator" step is the + # actual synchronization point (see that step for why it uses + # `bootstatus -b`, not a `simctl list | grep Booted` poll). run: | - COUNT=0 - MAX_RETRIES=12 # Wait for a maximum of 12 * 5 = 60 seconds - until xcrun simctl list devices | grep -q "Booted"; do - if [ $COUNT -ge $MAX_RETRIES ]; then - echo "Simulator did not boot within the expected time." - exit 1 - fi - echo "Waiting for simulator to boot... Attempt: $((COUNT+1))" - sleep 20 - COUNT=$((COUNT+1)) - done - echo "Simulator is ready." + SIM_JSON="$(xcrun simctl list devices available --json)" + RUNTIME_KEY="$(echo "$SIM_JSON" | jq -r '.devices | to_entries[] | select(.key|test("SimRuntime.iOS")) | select([.value[]|select(.name|startswith("iPhone"))]|length>0) | .key' | head -n 1)" + UDID="$(echo "$SIM_JSON" | jq -r --arg rt "$RUNTIME_KEY" '.devices[$rt][] | select(.name|startswith("iPhone")) | .udid' | head -n 1)" + OS_VER="$(echo "$RUNTIME_KEY" | sed -E 's/.*SimRuntime\.iOS-([0-9]+)-([0-9]+).*/\1.\2/')" + : "${UDID:?No available iPhone simulator found}" + : "${OS_VER:?Could not parse simulator OS version}" + echo "Booting UDID=$UDID on iOS $OS_VER (runtime $RUNTIME_KEY) in the background" + nohup xcrun simctl boot "$UDID" > "$RUNNER_TEMP/simctl-boot.log" 2>&1 & + disown + echo "SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + echo "SIMULATOR_OS=$OS_VER" >> "$GITHUB_ENV" - name: Run flutter precache run: flutter precache -v + - name: Activate Patrol CLI (patrol_cli_plus) + # patrol_cli_plus >= 5.4.1 defaults xcodebuild -destination-timeout to 30s, + # fixing the exit-70 device-resolution race on the slow macos-26 simulator. + run: dart pub global activate patrol_cli_plus + - name: Wait for Simulator to be Ready + # `xcrun simctl boot` (kicked off above, in the background) only + # *initiates* the boot and returns almost immediately; `simctl list + # devices` can report "Booted" before CoreSimulatorService has + # actually finished registering the device with Xcode's destination + # resolver. patrol_cli_plus's `test-without-building` invocation + # targets the simulator by exact UDID (`-destination id=`, + # unambiguous, no `--ios`/OS-string matching involved for simulators + # as of patrol_cli_plus 5.2.0) but pairs it with a hardcoded + # `-destination-timeout 1` (one second) on older releases. On PR + # #344's first macos-26 canary run (job 85040957868), a + # boot-then-poll-for-"Booted" pattern lost that race: xcodebuild + # reported "Unable to find a device matching the provided destination + # specifier" for the exact same booted UDID (exit 70, before any test + # ran) -- a CoreSimulatorService registration race, not a + # destination-string mismatch. `simctl bootstatus -b` is + # Apple's CI-safe boot primitive: it boots the device if it isn't + # already booting/booted, and blocks until CoreSimulatorService + # reports it has *actually* finished booting, closing this race at + # the source. Calling it here (after the background kick-off above) + # is idempotent: if the device is already booted it returns + # immediately, and if it's still booting it waits the remainder. + run: | + xcrun simctl bootstatus "${SIMULATOR_UDID:?Simulator UDID was not exported}" -b + echo "Simulator is ready." + - name: Start iOS diagnostics + # patrol forwards none of the app's Dart print/logging output on iOS, + # even with --verbose (the other jobs all show it), so a conformance + # module that fails here had only its failure line to go on (#469). + # + # Cheap by default, because the host is the thing under suspicion: a + # 3s ps/vm_stat sampler (host-load.log) and nothing else while the + # tests run. The app's "flutter: " lines and runningboardd's WebKit + # launch lines are read back from the simulator's persisted unified + # log AFTER the tests ("Collect iOS diagnostics"), which adds no load + # during them. The debug-level live stream this replaces cost "log" + # plus diagnosticd 40-60% CPU on a host already at load 200-500 (run + # 37430662455). + # + # IOS_HEAVY_DIAGNOSTICS (workflow_dispatch input) brings back live + # streams of the app, SafariViewService and SpringBoard/backboardd/ + # runningboardd, plus a continuous screen recording. That recording + # ruled out a dialog: during a 45s stall it showed the browser sheet + # with a blank page and no alert. + run: | + D="$RUNNER_TEMP/simlog" + mkdir -p "$D" + : > "$D/pids" + touch "$D/tests-start" + nohup bash -c ' + while true; do + echo "=== $(date -u +%H:%M:%S)" + sysctl -n vm.loadavg + # ps, not top: top -l costs about a CPU-second per sample on a + # 3-vCPU runner, enough to perturb what it measures. + ps -Ao pcpu,rss,comm -r | head -8 + echo "-- top RSS" + ps -Ao rss,pcpu,comm -m | head -8 + vm_stat | grep -E "Pages (free|active|wired|compressed)|Pageouts|Swapouts" + sleep 3 + done + ' > "$D/host-load.log" 2>&1 & + echo $! >> "$D/pids" + if [ "$IOS_HEAVY_DIAGNOSTICS" = "true" ]; then + nohup xcrun simctl spawn "$SIMULATOR_UDID" log stream --style compact --level info \ + --predicate 'process == "Runner" OR process == "SafariViewService" OR subsystem BEGINSWITH "com.apple.AuthenticationServices"' \ + > "$D/simulator.log" 2>&1 & + echo $! >> "$D/pids" + nohup xcrun simctl spawn "$SIMULATOR_UDID" log stream --style compact --level info \ + --predicate 'process == "SpringBoard" OR process == "backboardd" OR process == "runningboardd"' \ + > "$D/system.log" 2>&1 & + echo $! >> "$D/pids" + # recordVideo needs SIGINT to finalise the mp4, so its pid is kept + # apart from the plain kill list. + nohup xcrun simctl io "$SIMULATOR_UDID" recordVideo --codec=h264 --force \ + "$D/screen.mp4" > "$D/recordVideo.log" 2>&1 & + echo $! > "$D/video.pid" + fi + - name: Integration Tests (Patrol) + run: | + export PATH="$PATH:$HOME/.pub-cache/bin" + # Maps this job's shard label to the CONFORMANCE_SHARD tag set + # patrol_test/app_test.dart understands (a comma-separated set of + # its _knownShards tags) -- see the job comment above for the + # 2-way, time-balanced split and why it's 2 shards, not 4. + case "${{ matrix.shard }}" in + heavy) CONFORMANCE_SHARD_VALUE="hybrid,implicit" ;; + light) CONFORMANCE_SHARD_VALUE="basic,rest" ;; + *) + echo "::error::unknown matrix.shard '${{ matrix.shard }}' - expected heavy or light" + exit 1 + ;; + esac + # Runs every patrol_test/*_test.dart (conformance + e2e + offline) on + # the iOS simulator and collects Dart coverage via the VM service. + # CONFORMANCE_SHARD (read by patrol_test/app_test.dart) restricts + # which of the 13 conformance plans this matrix leg registers; the 3 + # offline-mode tests in patrol_test/offline_mode_test.dart are not + # sharded and run in every leg (they're fast and run no network + # conformance flow). + patrol_plus test \ + --coverage \ + --coverage-ignore="**/*.g.dart" \ + --coverage-workspace \ + -d "${SIMULATOR_UDID:?Simulator UDID was not exported}" \ + --ios "${SIMULATOR_OS:?Simulator OS was not exported}" \ + --verbose \ + --dart-define=CI=true \ + --dart-define=CONFORMANCE_SHARD="$CONFORMANCE_SHARD_VALUE" \ + --dart-define=CONFORMANCE_FORCE_STALL_MODULE="${IOS_FORCE_STALL_MODULE:-}" \ + --dart-define=OIDC_CONFORMANCE_TOKEN=${OIDC_CONFORMANCE_TOKEN} + cp coverage/patrol_lcov.info "coverage/ios-${{ matrix.shard }}-coverage.info" + working-directory: packages/oidc/example + env: + OIDC_CONFORMANCE_TOKEN: ${{ secrets.OIDC_CONFORMANCE_TOKEN }} + - name: Collect iOS diagnostics + if: always() + run: | + D="$RUNNER_TEMP/simlog" + if [ "${{ job.status }}" != "success" ]; then + xcrun simctl io "$SIMULATOR_UDID" screenshot "$D/final.png" || true + fi + # `|| true` throughout: this shell runs with -e and pipefail, and + # video.pid only exists with heavy diagnostics on. + vpid="$(cat "$D/video.pid" 2>/dev/null || true)" + if [ -n "$vpid" ]; then + kill -INT "$vpid" 2>/dev/null || true + for _ in $(seq 1 60); do kill -0 "$vpid" 2>/dev/null || break; sleep 1; done + fi + if [ -f "$D/pids" ]; then + while read -r pid; do kill "$pid" 2>/dev/null || true; done < "$D/pids" + fi + # Read the evidence back from the simulator's persisted unified log + # now that the tests are done. Both kinds of line are logged at + # default level, which the log store persists. + xcrun simctl spawn "$SIMULATOR_UDID" log show --style compact --last 2h \ + --predicate 'process == "Runner" AND eventMessage CONTAINS "flutter: "' \ + > "$D/runner.log" 2>&1 || true + xcrun simctl spawn "$SIMULATOR_UDID" log show --style compact --last 2h \ + --predicate 'process == "runningboardd" AND eventMessage CONTAINS "com.apple.WebKit.WebContent"' \ + > "$D/launches.log" 2>&1 || true + echo "Crash reports written during the tests:" + find "$HOME/Library/Logs/DiagnosticReports" /Library/Logs/DiagnosticReports \ + -newer "$D/tests-start" -type f 2>/dev/null \ + | sed 's#.*/##' | sed -E 's/[-_][0-9].*//' | sort | uniq -c | sort -rn | head -20 || true + python3 "$GITHUB_WORKSPACE/.github/scripts/ios_ci_metrics.py" \ + --runner-log "$D/runner.log" --launch-log "$D/launches.log" \ + --host-load "$D/host-load.log" | tee "$D/metrics.txt" || true + # Conformance lines only, so the job log answers the common question + # without downloading the artifact. + grep -E 'flutter: .*(\[e2e\]|Suite verdict|Login flow threw)' \ + "$D/runner.log" | cut -c1-400 || true + gzip -9 "$D"/*.log || true + # Per-shard name: this job is sharded 2 ways (see the job comment + # above), and actions/upload-artifact requires unique artifact names + # within one workflow run. + - name: Upload iOS diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ios-${{ matrix.shard }}-diagnostics + path: | + ${{ runner.temp }}/simlog/*.log.gz + ${{ runner.temp }}/simlog/metrics.txt + ${{ runner.temp }}/simlog/final.png + ${{ runner.temp }}/simlog/screen.mp4 + if-no-files-found: warn + # Diagnostics only: this used to run unconditionally on every job, ahead + # of the tests, costing ~148s (run 37346215172) for information that is + # only ever useful once something has already gone wrong. Moved after + # the test step and gated on failure so the normal path doesn't pay for + # it, now 2x per shard instead of once. - name: Run flutter doctor + if: failure() run: flutter doctor -v - - name: Integration Tests - run: melos run integration_test:ios - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: ios-integration-coverage - path: packages/oidc/example/coverage/ios-coverage.info + name: ios-${{ matrix.shard }}-integration-coverage + path: packages/oidc/example/coverage/ios-${{ matrix.shard }}-coverage.info include-hidden-files: true if-no-files-found: error + web: strategy: fail-fast: false matrix: working-directory: ["packages/oidc/example"] runs-on: ubuntu-latest - timeout-minutes: 15 + # Root cause of past web-job flakiness (see #337): the OIDC redirect URI + # was hardcoded to localhost:22433 while this job never pinned --web-port, + # so Flutter's web server bound to a random port. The IdP's redirect could + # never land back on the app's own origin, so OidcWebCore's response + # Completer never resolved -- the conformance test just hung until + # Playwright's 10-minute per-test timeout killed it + # (Total:1/Successful:0/Failed:0, not an assertion failure). Fixed by + # pinning --web-port; confirmed green with conformance actually executing + # in run 28666114227 (web job: "OIDC Conformance Test" passed in 31s). + # + # The hardcoding itself is gone now. Matching two literals by hand was only + # ever a coincidence waiting to break, and it broke again the moment the + # origin had to move to satisfy OIDC Registration section 2. Both the app + # and the conformance harness now derive the redirect URI from the origin + # being served (packages/oidc/example/lib/web_redirect_uri.dart), so the + # flags below are the only place it is stated. + # + # `continue-on-error: true` used to sit here as a safety net for the live + # third-party IdP dependency, with a note that the job was "expected to be + # green now, not masked-red". It was masked-red. The workflow reported + # conclusion=success on 262bdfd, c34015a and 932fb6c while this job failed + # in every one of them, and Hybrid RP and Implicit RP had been failing on + # web since the day they were added -- for two runs not even failing, but + # vanishing from the tally entirely. + # + # A job that cannot fail the build cannot defend anything. The safety-net + # argument is real but it is served by the retries and timeouts inside the + # job, not by discarding its verdict. + # Patrol web runs through Playwright (patrol_cli activation + npx playwright + # install + the web build + the live conformance flow). The healthy run is + # ~25m30s, so 30 left no room: with --web-timeout 900000 per test and + # --web-workers 1, two tests hitting the cap exceed the job budget and the + # step is killed BEFORE the tally reconciler prints which tests vanished -- + # losing exactly the diagnosis it exists to produce. 45 keeps the + # reconciler reachable in the case it was written for. + timeout-minutes: 45 steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base - # for reference: https://github.com/firebase/flutterfire/blob/d41e30a808b47458d7ebd402862bcd55d8563ccb/.github/workflows/e2e_tests.yaml#L281-L289 - - name: Run Web Integration Tests + - name: Preload Flutter web artifacts + run: flutter precache --web + # Patrol drives web via Playwright (its own bundled Chromium over CDP), + # NOT flutter-drive/DWDS, so Flutter's DWDS startup race + # (flutter/flutter#181357) never applies. patrol_cli runs + # `npx playwright install` itself; ubuntu-latest ships Node/npx. + - name: Activate Patrol CLI (patrol_cli_plus) + # patrol_cli_plus >= 5.5.0 forwards --web-hostname and the + # --web-tls-cert-path/--web-tls-cert-key-path pair, and trusts the + # supplied certificate in its own Dart-side readiness probe + # (Bdaya-Dev/patrol#44). All three are needed to serve the RP at the + # conformant origin the step below builds; 5.4.1 has none of them. + run: dart pub global activate patrol_cli_plus + # ubuntu-latest is 24.04, which restricts unprivileged user namespaces via + # AppArmor; re-allow them so Playwright's Chromium sandbox can initialize. + # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md + - name: Allow Chromium sandbox (unprivileged userns) + run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns + # An OIDC relying party running the implicit or hybrid flow may not be + # served at http, and may not be served at localhost. OpenID Connect + # Dynamic Client Registration 1.0 section 2: + # + # Web Clients using the OAuth Implicit Grant Type MUST only register + # URLs using the https scheme as redirect_uris; they MUST NOT use + # localhost as the hostname. + # + # Both clauses bind, and violating the first is what produced exactly 75 + # provider-side rejections in one run (48 Hybrid + 27 Implicit): + # + # redirect_uri is one of the registered uris but uses http scheme which + # is not allowed when application_type is web and response type is not + # code + # + # The suite only checks the scheme today, so https://localhost would turn + # the job green while still violating the second clause. It is not worth + # certifying against a check the suite forgot to write, so this serves the + # app at a real name instead. + # + # .test is reserved by RFC 6761 and is guaranteed never to resolve + # publicly, so this name cannot collide with a real host. + - name: Serve the RP at a conformant origin (hosts + TLS) working-directory: ${{ matrix.working-directory }} run: | - # first we run chromedriver on port 4444 and Xvfb - export DISPLAY=:99 - chromedriver --port=4444 --trace-buffer-size=100000 & - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional - - # then we start flutter drive - flutter drive --driver=./test_driver/integration_test.dart --target=./integration_test/app_test.dart -d chrome --dart-define=CI=true | tee output.log - # We have to check the output for failed tests matching the string "[E]" - output=$( -> + # authResult ...' line, the 'Suite status for ' dump that + # exists precisely to say WHY a module returned no user, and the + # 'PLAN_ID' listing -- all travel that path (print -> console -> + # Playwright stdout). Without it a failing profile reports nothing + # whatsoever: run 90178636000 logged ZERO occurrences of '[e2e]', + # 'PLAN_ID' and 'Suite status', including for the 11 tests that + # PASSED. --verbose does NOT cover this; it only raises the CLI's own + # logger level. + # + # --web-timeout states Playwright's per-test cap instead of inheriting + # it. 600000ms is playwright.config.ts:66's own default, pinned here + # because test/conformance_flow_timeout_test.dart asserts the + # conformance flow timeouts fit inside it (48 hybrid modules x 10s + + # setup < 600s). Inheriting a number a pub package can change would + # let that arithmetic silently stop holding. + set +e + patrol_plus test \ + --target patrol_test/app_test.dart \ + --web-port 22433 \ + --web-hostname rp.oidc.test \ + --web-tls-cert-path "$PWD/.ci/tls/rp.pem" \ + --web-tls-cert-key-path "$PWD/.ci/tls/rp.key" \ + --web-browser-args '["--ignore-certificate-errors"]' \ + --coverage \ + --coverage-ignore="**/*.g.dart" \ + --coverage-package oidc \ + -d chrome \ + --web-headless true \ + --web-workers 1 \ + --web-timeout 900000 \ + --show-flutter-logs \ + --verbose \ + --dart-define=CI=true \ + --dart-define=OIDC_CONFORMANCE_TOKEN=$OIDC_CONFORMANCE_TOKEN \ + 2>&1 | tee patrol-web.log + PATROL_RC=${PIPESTATUS[0]} + set -e + echo "patrol exit status: $PATROL_RC" + + # Best-effort: web V8->lcov path differs from native; don't fail the + # job if the file isn't produced. + cp coverage/patrol_lcov.info coverage/web-coverage.info || \ + echo "No web lcov produced (V8 coverage may be unavailable)." + + # A test that starts and never reports is counted in patrol's `Total` + # and in NONE of successful/failed/skipped, because an unclosed entry + # keeps `start` status and no counter matches it + # (patrol_single_test_entry.dart:33). That is how this job printed + # "Total: 13, Successful: 11, Failed: 0, Skipped: 0" and exited 1 + # without naming either lost profile, and why the terminal message was + # the misleading "Playwright process exited unexpectedly with code 1" + # rather than "Some tests failed." (web_test_backend.dart:649-654 + # keys on failedTestsCount > 0). + # + # Nothing in patrol asserts successful + failed + skipped == total. + # Do it here, over patrol's own output, so a vanished test is a NAMED + # failure. The floor depends on which path the harness took: + # * token present -> 13: patrol_test/app_test.dart declares 13 + # conformance tests, and patrol reports a run that executed + # nothing as a clean pass. + # * token absent -> 1: the harness runs its smoke path ("Running + # smoke test path (no OIDC token supplied)"). This is the only + # path a dependabot-triggered run can take: dependabot PRs get a + # separate secret store ("Secret source: Dependabot") that does + # not contain OIDC_CONFORMANCE_TOKEN, so demanding 13 made every + # dependabot PR structurally red here. The floor stays POSITIVE + # (never 0): a smoke run that executed nothing still fails. + if [ -n "$OIDC_CONFORMANCE_TOKEN" ]; then + MIN_TESTS=13 + else + MIN_TESTS=1 + echo "::notice::OIDC_CONFORMANCE_TOKEN is not available in this trigger context (e.g. a dependabot PR). Live conformance did not run; the harness ran its smoke path and the tally floor is $MIN_TESTS." + fi + set +e + dart run tool/patrol_tally.dart patrol-web.log --minimum-tests "$MIN_TESTS" + TALLY_RC=$? + set -e + + # Deferred so the tally always runs and always names what it found. + if [ "$PATROL_RC" -ne 0 ]; then + echo "::error::patrol reported failure (exit $PATROL_RC); see the test output above." + exit "$PATROL_RC" fi + exit "$TALLY_RC" shell: bash - # web has no test coverage for now - # - name: Upload Coverage Report - # uses: actions/upload-artifact@v4 - # with: - # name: web-coverage - # path: packages/oidc/example/coverage/web-coverage.info + # if: always() -- these exist to explain a FAILED run, so the step that + # uploads them must survive the failure. Without it the job went straight + # from "##[error]Process completed with exit code 1" to "Post job + # cleanup", and Playwright's own HTML report -- the one artifact that + # names the tests it killed on timeout, which patrol's summary cannot -- + # died with the runner. + - name: Upload Web Test Diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: web-test-diagnostics + path: | + packages/oidc/example/patrol-web.log + packages/oidc/example/playwright-report + packages/oidc/example/test-results + include-hidden-files: true + if-no-files-found: warn + + - name: Upload Coverage Report + uses: actions/upload-artifact@v7 + with: + name: web-integration-coverage + path: packages/oidc/example/coverage/web-coverage.info + include-hidden-files: true + # The artifact is absent whenever the run failed before coverage was + # written, which is a normal outcome of a failing conformance run -- + # the job's verdict already carries that. Warn rather than add a + # second failure for the same cause. + if-no-files-found: warn macos: - runs-on: macos-13 + runs-on: macos-latest + timeout-minutes: 60 + # Deliberately NOT sharded, unlike android/ios/linux/windows -- two + # reasons, both confirmed live rather than assumed: + # + # 1. It's already fast. Baseline run 37346215172: macos finished in + # 7m51s (17:08:52 -> 17:16:43), far below ios's ~29min for the same 13 + # plans. Most of the plans that make ios slow are either quick here or + # (per integration_test/shared_e2e.dart's skip logic) not runnable on + # macOS at all -- e.g. the 4 logout plans skip because macOS's + # com.bdayadev.oidc.example:/oauth2redirect custom scheme has no + # authority for GenerateSessionState to build an origin from, and the + # 3 form-post plans skip because macOS can't receive an HTTP POST. + # Sharding has nothing worth parallelizing here. + # 2. It would actively hurt. This job and `ios` are the only two + # consumers, in this workflow, of GitHub's *concurrent macOS jobs* + # pool, which is capped at 5 on Free/Pro/Team regardless of the much + # larger general concurrent-job limit (see + # https://docs.github.com/en/actions/reference/limits, "Usage + # limits"). An earlier version of this job sharded 4 ways, which + # together with ios's own 4 shards asked for 8 concurrent macOS-pool + # slots at once. Measured live on run 37398194429: ios shards started + # at 01:15:08 / 01:18:23 / 01:29:43 / 01:39:46 and the run didn't + # finish until 01:57:49 -- 42 minutes end-to-end, WORSE than the + # unsharded baseline, because most shards queued behind the cap + # instead of running in parallel. Leaving this job as the single + # consumer it always was, plus ios's reduced 2-way split (see that + # job's comment), brings this workflow's own macOS-pool demand down + # to 3 -- comfortably under 5. defaults: run: working-directory: packages/oidc/example steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base - name: Run flutter precache run: flutter precache -v - name: Integration Tests - run: melos run integration_test:macos + working-directory: packages/oidc/example + run: | + # Work around flaky desktop integration test runs when executing + # multiple test entrypoints in a single `flutter test integration_test`. + flutter test integration_test/app_test.dart \ + --coverage --branch-coverage --coverage-package "^(oidc|jose_plus|x509_plus|crypto_keys_plus)" \ + -d macos --coverage-path coverage/macos-app-coverage.info \ + --dart-define=CI=true --dart-define=OIDC_CONFORMANCE_TOKEN=${{env.OIDC_CONFORMANCE_TOKEN}} \ + -r expanded + + flutter test integration_test/offline_mode_test.dart \ + --coverage --branch-coverage --coverage-package "^(oidc|jose_plus|x509_plus|crypto_keys_plus)" \ + -d macos --coverage-path coverage/macos-offline-coverage.info \ + --dart-define=CI=true --dart-define=OIDC_CONFORMANCE_TOKEN=${{env.OIDC_CONFORMANCE_TOKEN}} \ + -r expanded + env: + OIDC_CONFORMANCE_TOKEN: ${{ secrets.OIDC_CONFORMANCE_TOKEN }} - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-integration-coverage - path: packages/oidc/example/coverage/macos-coverage.info + path: packages/oidc/example/coverage/macos-*-coverage.info include-hidden-files: true if-no-files-found: error linux: runs-on: ubuntu-latest + # Sharded 4 ways like android (see that job's comment on why 4, not the + # 2 ios uses): setup here is cheap (apt packages + flutter precache, no + # emulator/simulator), so the 13 conformance plans running in parallel + # matrix legs instead of one sequential job is close to a pure wall-time + # win, with no macOS-pool-style concurrency budget to conserve. + strategy: + fail-fast: false + matrix: + shard: [basic, implicit, hybrid, rest] + # container: + # image: selenium/standalone-chrome:latest + # options: --user root defaults: run: working-directory: packages/oidc/example steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base - name: Install Dependencies run: | sudo apt-get update - sudo apt-get install -y libsecret-1-dev libgtk-3-dev libx11-dev pkg-config cmake ninja-build libblkid-dev liblzma-dev + sudo apt-get install -y xvfb libsecret-1-dev libgtk-3-dev libx11-dev pkg-config cmake ninja-build libblkid-dev liblzma-dev xdg-utils libvulkan1 fonts-liberation libatspi2.0-dev xdotool + shell: bash - name: Run flutter precache run: flutter precache -v - - name: Integration Tests - run: melos run integration_test:linux + - name: Activate Patrol CLI (patrol_cli_plus) + run: dart pub global activate patrol_cli_plus + - name: Integration Tests (Patrol) + working-directory: packages/oidc/example + env: + OIDC_CONFORMANCE_TOKEN: ${{ secrets.OIDC_CONFORMANCE_TOKEN }} + run: | + export PATH="$PATH:$HOME/.pub-cache/bin" + if [ -z "$OIDC_CONFORMANCE_TOKEN" ]; then + echo "OIDC_CONFORMANCE_TOKEN is not set or is empty" + else + echo "OIDC_CONFORMANCE_TOKEN is set (value is masked for security)" + fi + google-chrome --version + chmod +x .ci/scripts/set_default_linux_apps.sh + ./.ci/scripts/set_default_linux_apps.sh + # patrol_cli_plus drives the Linux desktop backend (via AT-SPI2 / + # xdotool, installed above). A single invocation runs every + # patrol_test/*_test.dart (conformance + e2e + offline) and collects + # Dart coverage via the VM service. + # --verbose because patrol forwards NO test stdout to the job log by + # default: a failing test reports only "1 test(s) failed", with the + # reason, the module name, and even plain print() output all dropped. + # The ios job already runs verbose for the same reason. + xvfb-run --auto-servernum patrol_plus test \ + --coverage \ + --coverage-ignore="**/*.g.dart" \ + --coverage-workspace \ + -d linux \ + --verbose \ + --dart-define=CI=true \ + --dart-define=CONFORMANCE_SHARD=${{ matrix.shard }} \ + --dart-define=OIDC_CONFORMANCE_TOKEN=$OIDC_CONFORMANCE_TOKEN + cp coverage/patrol_lcov.info "coverage/linux-${{ matrix.shard }}-coverage.info" - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 + with: + name: linux-${{ matrix.shard }}-integration-coverage + path: packages/oidc/example/coverage/linux-${{ matrix.shard }}-coverage.info + include-hidden-files: true + if-no-files-found: error + - name: Upload Conformance Logs + uses: actions/upload-artifact@v7 with: - name: linux-integration-coverage - path: packages/oidc/example/coverage/linux-coverage.info + name: oidc-client-logs-${{ matrix.shard }} + path: packages/oidc/example/client-logs/final.zip include-hidden-files: true - if-no-files-found: error + if-no-files-found: warn windows: runs-on: windows-latest + # Sharded like linux (see that job's comment): cheap setup, so splitting + # the 13 conformance plans into parallel matrix legs is close to a pure + # wall-time win. + strategy: + fail-fast: false + matrix: + shard: [basic, implicit, hybrid, rest] defaults: run: working-directory: packages/oidc/example steps: - - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base - name: Run flutter precache run: flutter precache -v - - name: Integration Tests - run: dart pub global run melos run integration_test:windows + - name: Activate Patrol CLI (patrol_cli_plus) + run: dart pub global activate patrol_cli_plus + - name: Integration Tests (Patrol) + working-directory: packages/oidc/example + run: | + export PATH="$PATH:$HOME/AppData/Local/Pub/Cache/bin:$HOME/.pub-cache/bin" + # patrol_cli_plus drives the Windows desktop backend. A single + # invocation runs every patrol_test/*_test.dart (conformance + e2e + + # offline) and collects Dart coverage via the VM service. + # + # `dart pub global activate` on Windows installs `patrol_plus.bat` + # (a cmd shim), NOT an extensionless shell script. git-bash does NOT + # honor PATHEXT, so bare `patrol_plus` is "command not found" (exit + # 127) even with the pub-cache bin on PATH. Invoke the .bat by its + # full name — git-bash resolves it on PATH and execs it via cmd. + # --verbose to match ios/web/linux: without it patrol drops all test + # stdout, so windows reports "Results: 4 passed" with no "[e2e]" line + # and its conformance login count is unobservable. + patrol_plus.bat test \ + --coverage \ + --coverage-ignore="**/*.g.dart" \ + --coverage-workspace \ + -d windows \ + --verbose \ + --dart-define=CI=true \ + --dart-define=CONFORMANCE_SHARD=${{ matrix.shard }} \ + --dart-define=OIDC_CONFORMANCE_TOKEN=${{ env.OIDC_CONFORMANCE_TOKEN }} + cp coverage/patrol_lcov.info "coverage/windows-${{ matrix.shard }}-coverage.info" + env: + OIDC_CONFORMANCE_TOKEN: ${{ secrets.OIDC_CONFORMANCE_TOKEN }} shell: bash - name: Upload Coverage Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: windows-integration-coverage - path: packages/oidc/example/coverage/windows-coverage.info + name: windows-${{ matrix.shard }}-integration-coverage + path: packages/oidc/example/coverage/windows-${{ matrix.shard }}-coverage.info include-hidden-files: true - if-no-files-found: error - + if-no-files-found: error + upload-coverage: runs-on: ubuntu-latest - needs: [unit_tests, android, ios, macos, linux, windows] + # `web` no longer carries continue-on-error, so a web FAILURE now skips + # this job rather than letting it run with a missing artifact. That is the + # intended trade: a run whose conformance job failed is red already, and a + # coverage upload from it would be partial anyway. + # + # `needs:` replaces the old single `unit_tests` job with every job the + # 2026 unit-test split introduced (see that block's header comment). + # unit_jose_chrome/unit_jose_firefox/unit_webcore_coverage skip entirely + # on PRs (job-level `if:`), and GitHub's default needs-cascade would + # therefore skip THIS job on every PR run too -- exactly the behavior + # change this `if:` exists to prevent. It requires every job to have + # actually succeeded, except those three, which may legitimately be + # 'skipped'; `always()` is required for the `if:` to even evaluate once + # any dependency is skipped rather than succeeded. + needs: + [ + unit_core, + unit_chrome, + unit_firefox, + unit_jose_chrome, + unit_jose_firefox, + unit_webcore_coverage, + unit_oidc_web_chrome, + unit_flutter, + unit_tests_coverage, + android, + ios, + web, + macos, + linux, + windows, + ] + if: | + always() && + needs.unit_core.result == 'success' && + needs.unit_chrome.result == 'success' && + needs.unit_firefox.result == 'success' && + (needs.unit_jose_chrome.result == 'success' || needs.unit_jose_chrome.result == 'skipped') && + (needs.unit_jose_firefox.result == 'success' || needs.unit_jose_firefox.result == 'skipped') && + (needs.unit_webcore_coverage.result == 'success' || needs.unit_webcore_coverage.result == 'skipped') && + needs.unit_oidc_web_chrome.result == 'success' && + needs.unit_flutter.result == 'success' && + needs.unit_tests_coverage.result == 'success' && + needs.android.result == 'success' && + needs.ios.result == 'success' && + needs.web.result == 'success' && + needs.macos.result == 'success' && + needs.linux.result == 'success' && + needs.windows.result == 'success' steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Environment uses: ./.github/actions/integration_test_base - name: Download unit test coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: package-coverage path: coverage/downloaded/ @@ -316,12 +1836,63 @@ jobs: rm -rf coverage/downloaded ls -lR coverage - name: Download integration coverages - uses: actions/download-artifact@v4 - with: + uses: actions/download-artifact@v8 + with: path: packages/oidc/example/coverage/ - pattern: '*-integration-coverage' + pattern: "*-integration-coverage" merge-multiple: true - + + - name: Normalize macOS-absolute SF paths + env: + REPO_PATH: ${{ github.workspace }} + # The ios and macos jobs run on macOS runners, so their lcov files + # carry SF: paths under /Users/runner/work/oidc/oidc/... while this + # job's combine_coverage strips the LINUX workspace prefix + # ($REPO_PATH = /home/runner/work/oidc/oidc). Unrewritten, those + # entries (106 of them on run 28821969853) stay absolute in + # final-coverage and Codecov cannot map them to repo files. Rewrite + # the macOS prefix to this job's workspace prefix so the combine + # relativizes every record uniformly. + run: | + set -e + find packages/oidc/example/coverage -type f -name "*.info" -exec sed -i "s|^SF:/Users/runner/work/oidc/oidc/|SF:$REPO_PATH/|" {} + + # `|| true` inside the substitution: in the healthy zero-remaining + # case grep exits 1 (no matching lines), and under this workflow's + # pipefail+errexit shell that would fail the step; awk's stdout + # ("0") is captured either way. + remaining=$(grep -rhc '^SF:/Users/' packages/oidc/example/coverage --include='*.info' | awk '{s+=$1} END {print s+0}' || true) + echo "macOS-absolute SF entries remaining after normalization: $remaining" + # A platform that stops running tests emits an empty lcov, and nothing else + # catches that: `if-no-files-found: error` only asserts the file exists, and + # codecov.yml disables every status (`patch: false`, `project: false`). The + # sibling guard below covers unit_tests' packages but not these. + # + # An empty lcov is not a coverage regression, it is proof the platform ran + # nothing, so fail and name it. + - name: "[Coverage] Assert every integration coverage is non-empty" + run: | + missing=0 + found=0 + for f in packages/oidc/example/coverage/*-coverage.info; do + [ -e "$f" ] || continue + found=$((found + 1)) + bytes=$(wc -c < "$f") + sf=$(grep -c '^SF:' "$f" || true) + sf=${sf:-0} + echo "guard: $(basename "$f") -> ${bytes} bytes, ${sf} SF record(s)" + if [ "$sf" -eq 0 ]; then + echo "::error::$(basename "$f") has zero source records - that platform executed no tests." + missing=1 + fi + done + if [ "$found" -eq 0 ]; then + echo "::error::no *-coverage.info found at all - every integration job failed to produce coverage" + missing=1 + fi + if [ "$missing" -ne 0 ]; then + exit 1 + fi + echo "guard: all ${found} integration coverage file(s) carry real source records" - name: Combine Coverage Results env: REPO_PATH: ${{ github.workspace }} @@ -330,16 +1901,26 @@ jobs: find . -type f -name "*-coverage.info" dart pub global activate combine_coverage dart pub global run combine_coverage --repo-path="$REPO_PATH" + - name: Strip generated files from combined coverage + # Parity with the unit pipeline's coverage:combine: without this the + # final-coverage artifact and the Codecov upload include *.g.dart and + # the generated oidc_cli version.dart from integration runs. + run: | + set -e + dart pub global activate remove_from_coverage + dart pub global run remove_from_coverage:remove_from_coverage \ + -f coverage/lcov.info \ + -r '\.g\.dart$' -r 'oidc_cli/lib/src/version\.dart$' # Upload - name: Upload the final coverage as an artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: final-coverage path: coverage/lcov.info - name: Report to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - file: coverage/lcov.info - fail_ci_if_error: false \ No newline at end of file + files: coverage/lcov.info + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 35529d52..10adbaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,9 +41,20 @@ GeneratedPluginRegistrant.h GeneratedPluginRegistrant.m GeneratedPluginRegistrant.swift build/ +# Swift Package Manager build artifacts +.build/ +.swiftpm/ .flutter-plugins .project .classpath .settings -pubspec_overrides.yaml \ No newline at end of file +pubspec_overrides.yaml + +# oh-my-claudecode local session/state, plans, research & logs (not source) +.omc/ + +# Claude Code local state (agent worktrees, local settings — not source) +.claude/ +# Session handoff artifacts (local deliverables, not repo content) +session-artifacts/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 9ca3d394..70fa961f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,11 +27,12 @@ "name": "example flutter web", "cwd": "packages/oidc/example", "request": "launch", - "type": "dart", + "type": "dart", "deviceId": "chrome", "args": [ "--web-port", - "22433" + "22433", + // "--wasm" ], }, { diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 00000000..cdc91273 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + // requires first running + // mkdir -p ~/.cache/ietf-doc-server + // pip install git+https://github.com/tizee/mcp-server-ietf.git + "ietf": { + "command": "mcp-server-ietf", + "args": [ + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9790ba54..811d0d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,1261 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2026-10-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc` - `v5.0.2`](#oidc---v502) + +--- + +#### `oidc` - `v5.0.2` + + - **FIX**(ci): respect GitHub's 5-concurrent-macOS-jobs cap, un-shard macos. ([79e50312](https://github.com/Bdaya-Dev/oidc/commit/79e50312fe385d5cf27f1c304c2bf0ee989d2254)) + + +## 2026-10-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc_web_core` - `v1.2.2`](#oidc_web_core---v122) + - [`oidc_web` - `v1.0.5`](#oidc_web---v105) + - [`oidc` - `v5.0.1`](#oidc---v501) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_web` - `v1.0.5` + - `oidc` - `v5.0.1` + +--- + +#### `oidc_web_core` - `v1.2.2` + + - **FIX**(oidc_web_core): compare the session iframe's window by JS identity. ([1dc7598f](https://github.com/Bdaya-Dev/oidc/commit/1dc7598fa5eb74d015d2a0e8dfe7f673711ba41e)) + + +## 2026-10-05 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v5.0.0`](#oidc---v500) + - [`oidc_core` - `v4.0.0`](#oidc_core---v400) + - [`oidc_default_store` - `v2.0.0`](#oidc_default_store---v200) + +Packages with other changes: + + - [`jose_plus` - `v1.0.1`](#jose_plus---v101) + - [`oidc_loopback_listener` - `v1.1.1`](#oidc_loopback_listener---v111) + - [`oidc_web_core` - `v1.2.1`](#oidc_web_core---v121) + - [`oidc_android` - `v3.0.1`](#oidc_android---v301) + - [`oidc_cli` - `v1.0.4`](#oidc_cli---v104) + - [`oidc_desktop` - `v1.1.1`](#oidc_desktop---v111) + - [`oidc_darwin` - `v1.1.3`](#oidc_darwin---v113) + - [`oidc_linux` - `v1.0.4`](#oidc_linux---v104) + - [`oidc_platform_interface` - `v1.0.4`](#oidc_platform_interface---v104) + - [`oidc_web` - `v1.0.4`](#oidc_web---v104) + - [`oidc_windows` - `v1.0.4`](#oidc_windows---v104) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_android` - `v3.0.1` + - `oidc_cli` - `v1.0.4` + - `oidc_desktop` - `v1.1.1` + - `oidc_darwin` - `v1.1.3` + - `oidc_linux` - `v1.0.4` + - `oidc_platform_interface` - `v1.0.4` + - `oidc_web` - `v1.0.4` + - `oidc_windows` - `v1.0.4` + +--- + +#### `oidc` - `v5.0.0` + + - **FIX**(example): stop corrupting the web conformance POST through cors-proxy. ([4eea7cd8](https://github.com/Bdaya-Dev/oidc/commit/4eea7cd840c208582eaf873a0d34ce54e7e2a95e)) + - **FIX**(oidc): rethrow platform OidcException from getPlatformEndSessionResponse unwrapped. ([ab422b70](https://github.com/Bdaya-Dev/oidc/commit/ab422b70d38093ab4e85a56176df78908780ba71)) + - **FIX**: await futures returned inside try blocks (unawaited_return_in_try_block). ([452d6ee1](https://github.com/Bdaya-Dev/oidc/commit/452d6ee121674dee760f2d698f2a7d5565ad6600)) + - **FIX**(example): register the RP's true application_type with the test plan. ([1253d9be](https://github.com/Bdaya-Dev/oidc/commit/1253d9be404bd36ed5d8f83521ce74efb3ca73f1)) + - **FEAT**(oidc_core): mTLS transport, end-to-end alias routing and web guard ([#386](https://github.com/Bdaya-Dev/oidc/issues/386)). ([d3bd7213](https://github.com/Bdaya-Dev/oidc/commit/d3bd721399ddf579067791f12c536b2fb4022ad5)) + - **FEAT**(oidc_core): adopt and persist RFC 7592 credential rotation through the manager ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)). ([0b6f57c1](https://github.com/Bdaya-Dev/oidc/commit/0b6f57c199bc3d8b76bf05845b5a0b13c1d9bcd8)) + - **BREAKING** **FIX**(oidc_core): reject a discovery document whose issuer does not match. ([fab65a06](https://github.com/Bdaya-Dev/oidc/commit/fab65a069d2b4bd4ac8d41fc78e6c194702f32b4)) + - **BREAKING** **FEAT**(oidc_default_store): bump flutter_secure_storage to ^11 ([#451](https://github.com/Bdaya-Dev/oidc/issues/451)). ([166f394e](https://github.com/Bdaya-Dev/oidc/commit/166f394ed7d937f1098a558b92f325fc52043700)) + +#### `oidc_core` - `v4.0.0` + + - **FIX**(oidc_core): probe check_session_iframe once more after RP-initiated logout. ([c3540c22](https://github.com/Bdaya-Dev/oidc/commit/c3540c2242b6aa19589e7ac2996e3b27141e78a1)) + - **FIX**(oidc_core): require at_hash on the implicit id_token token response. ([6cd96cd0](https://github.com/Bdaya-Dev/oidc/commit/6cd96cd0a5d6d629a82f9e473cdfc829a8bc71d0)) + - **FIX**(oidc_core): settle userChangesAfterInit from inside init(), not via initFuture ([#453](https://github.com/Bdaya-Dev/oidc/issues/453)). ([a349ae98](https://github.com/Bdaya-Dev/oidc/commit/a349ae98f337ef013f8e3b3a2d943edd98df97ec)) + - **FIX**(oidc_core): userChangesAfterInit — single init attachment, closed-controller guards ([#453](https://github.com/Bdaya-Dev/oidc/issues/453)). ([e239f8a3](https://github.com/Bdaya-Dev/oidc/commit/e239f8a3e9d1e81e27ec1c51f6398b55f9f5453e)) + - **FIX**: await futures returned inside try blocks (unawaited_return_in_try_block). ([452d6ee1](https://github.com/Bdaya-Dev/oidc/commit/452d6ee121674dee760f2d698f2a7d5565ad6600)) + - **FIX**(oidc_core): close mTLS phase 2 review gaps ([#386](https://github.com/Bdaya-Dev/oidc/issues/386)). ([85bc47c8](https://github.com/Bdaya-Dev/oidc/commit/85bc47c88bf9e294ed6c3f759f2979286b25489c)) + - **FIX**(oidc_core): don't retain userChangesAfterInit listeners until dispose ([#453](https://github.com/Bdaya-Dev/oidc/issues/453)). ([c0d8c48f](https://github.com/Bdaya-Dev/oidc/commit/c0d8c48f6422c2d746ead72db6c9063be30561bc)) + - **FIX**(oidc_core): require c_hash and at_hash on the hybrid front-channel id_token. ([7b3b2ec6](https://github.com/Bdaya-Dev/oidc/commit/7b3b2ec669cf21ef268d79ed55bc786c903e8be0)) + - **FEAT**(oidc_core): report the post-logout session check as OidcEndSessionConfirmationEvent. ([457e32c5](https://github.com/Bdaya-Dev/oidc/commit/457e32c53ea0b59ac293dd57dd5e5ae79016766a)) + - **FEAT**(oidc_core): mTLS transport, end-to-end alias routing and web guard ([#386](https://github.com/Bdaya-Dev/oidc/issues/386)). ([d3bd7213](https://github.com/Bdaya-Dev/oidc/commit/d3bd721399ddf579067791f12c536b2fb4022ad5)) + - **FEAT**(oidc_core): adopt and persist RFC 7592 credential rotation through the manager ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)). ([0b6f57c1](https://github.com/Bdaya-Dev/oidc/commit/0b6f57c199bc3d8b76bf05845b5a0b13c1d9bcd8)) + - **FEAT**(oidc_core): add userChangesAfterInit() to tell "not initialized" from "signed out". ([29b09f77](https://github.com/Bdaya-Dev/oidc/commit/29b09f77f71e249f7de5a4b102014edb65f4f400)) + - **DOCS**(oidc_core): do not present `changed` as proof the OP logout worked. ([333d0a64](https://github.com/Bdaya-Dev/oidc/commit/333d0a645aa3d123b4e37d29cb4687f07a73e6be)) + - **DOCS**(oidc_core): document Azure AD B2C and Entra options for strict issuer validation. ([c9ca9cdd](https://github.com/Bdaya-Dev/oidc/commit/c9ca9cdd8c3cc73dd0b74608a8c2794e71393221)) + - **DOCS**(oidc_core): state the exact DCR carry-over set ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)). ([9282adaa](https://github.com/Bdaya-Dev/oidc/commit/9282adaaf6d6716f24542f69228d8858b3d87b5d)) + - **DOCS**(oidc_core): align DCR dartdoc with manager-driven rotation ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)). ([43b3eeba](https://github.com/Bdaya-Dev/oidc/commit/43b3eeba665e99af6bdf682ec305fe4fddcb5b3a)) + - **BREAKING** **FIX**(oidc_core): re-verify the restored session when a background discovery refresh is rejected. ([80381f47](https://github.com/Bdaya-Dev/oidc/commit/80381f476d6afc6571ece70acc5c249ece02ac22)) + - **BREAKING** **FIX**(oidc_core): never keep or use a rejected discovery document. ([14db08c2](https://github.com/Bdaya-Dev/oidc/commit/14db08c276595973011135b3beced8221c073d07)) + - **BREAKING** **FIX**(oidc_core): reject a discovery document whose issuer does not match. ([fab65a06](https://github.com/Bdaya-Dev/oidc/commit/fab65a069d2b4bd4ac8d41fc78e6c194702f32b4)) + - **BREAKING** **FIX**(oidc_core): judge id_token hash requiredness from the response's own tokens ([#466](https://github.com/Bdaya-Dev/oidc/issues/466)). ([88d922b6](https://github.com/Bdaya-Dev/oidc/commit/88d922b6b079a8327f7c3ae0fdd5040a38d85ad7)) + - **BREAKING** **FEAT**(oidc_core): OidcIdTokenValidationContext for flow-aware id_token validation. ([d5538e50](https://github.com/Bdaya-Dev/oidc/commit/d5538e50423f29fa545c5b8108a6724c069d7576)) + +#### `oidc_default_store` - `v2.0.0` + + - **FIX**: await futures returned inside try blocks (unawaited_return_in_try_block). ([452d6ee1](https://github.com/Bdaya-Dev/oidc/commit/452d6ee121674dee760f2d698f2a7d5565ad6600)) + - **BREAKING** **FEAT**(oidc_default_store): bump flutter_secure_storage to ^11 ([#451](https://github.com/Bdaya-Dev/oidc/issues/451)). ([166f394e](https://github.com/Bdaya-Dev/oidc/commit/166f394ed7d937f1098a558b92f325fc52043700)) + +#### `jose_plus` - `v1.0.1` + + - **FIX**(jose_plus): pass apu/apv to the ECDH-ES Concat KDF in build(). ([aa20db62](https://github.com/Bdaya-Dev/oidc/commit/aa20db62140e7a43cf60224f2a163515aa101314)) + +#### `oidc_loopback_listener` - `v1.1.1` + + - **FIX**(oidc_loopback_listener): never relay a POST; its body is the response. ([db4381a7](https://github.com/Bdaya-Dev/oidc/commit/db4381a70c2063ffacad25b49531ca894b8981f1)) + +#### `oidc_web_core` - `v1.2.1` + + - **FIX**(oidc_web_core): require event.source to match a monitor's own iframe ([#474](https://github.com/Bdaya-Dev/oidc/issues/474)). ([b4346936](https://github.com/Bdaya-Dev/oidc/commit/b4346936cc6df00a6902c471cb8a6eaaf722180e)) + - **FIX**(oidc_web_core): each session monitor owns its own iframe ([#474](https://github.com/Bdaya-Dev/oidc/issues/474)). ([4771e740](https://github.com/Bdaya-Dev/oidc/commit/4771e74092858963581a7d571a6d9a2075a6bb73)) + + +## 2026-08-01 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v4.0.0`](#oidc---v400) + - [`oidc_android` - `v3.0.0`](#oidc_android---v300) + - [`oidc_core` - `v3.0.0`](#oidc_core---v300) + +Packages with other changes: + + - [`oidc_desktop` - `v1.1.0`](#oidc_desktop---v110) + - [`oidc_loopback_listener` - `v1.1.0`](#oidc_loopback_listener---v110) + - [`oidc_web_core` - `v1.2.0`](#oidc_web_core---v120) + - [`oidc_cli` - `v1.0.3`](#oidc_cli---v103) + - [`oidc_default_store` - `v1.1.2`](#oidc_default_store---v112) + - [`oidc_darwin` - `v1.1.2`](#oidc_darwin---v112) + - [`oidc_linux` - `v1.0.3`](#oidc_linux---v103) + - [`oidc_platform_interface` - `v1.0.3`](#oidc_platform_interface---v103) + - [`oidc_web` - `v1.0.3`](#oidc_web---v103) + - [`oidc_windows` - `v1.0.3`](#oidc_windows---v103) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_cli` - `v1.0.3` + - `oidc_default_store` - `v1.1.2` + - `oidc_darwin` - `v1.1.2` + - `oidc_linux` - `v1.0.3` + - `oidc_platform_interface` - `v1.0.3` + - `oidc_web` - `v1.0.3` + - `oidc_windows` - `v1.0.3` + +--- + +#### `oidc` - `v4.0.0` + + - **FIX**(example): keep the front-channel logout URI off Uri.base on native. ([be8a8968](https://github.com/Bdaya-Dev/oidc/commit/be8a896893bb78efcf7c9929368c2218a8d7e330)) + - **FIX**(example): serve the web RP at a conformant https origin. ([e62500b0](https://github.com/Bdaya-Dev/oidc/commit/e62500b06442ddd2bda20c934d4c1843aedaf3c3)) + - **FIX**(example): size the web per-test cap to the workload, not the reverse. ([932fb6ca](https://github.com/Bdaya-Dev/oidc/commit/932fb6caa9619af24ed37caf3ef31290b7a8eb57)) + - **FIX**(example): web loses Hybrid/Implicit to a timeout budget, not a fragment bug. ([624a502e](https://github.com/Bdaya-Dev/oidc/commit/624a502e04ceae7ac26ec05f3d523fe1a870bd7a)) + - **FIX**(example): gate Dynamic RP, which needs an RP-hosted request_uri. ([dda2caea](https://github.com/Bdaya-Dev/oidc/commit/dda2caea01f3a7e3bf89ac9c97a066aa72759bf1)) + - **FIX**(example): gate the logout profiles the suite cannot run, and count logouts. ([bfa48dbd](https://github.com/Bdaya-Dev/oidc/commit/bfa48dbdfeac73da6ab9a46840fe3ce86761287b)) + - **FIX**(example): let the suite supply Dynamic RP's module variant. ([05b0c847](https://github.com/Bdaya-Dev/oidc/commit/05b0c84730d198fd7795692d4e38165733190a06)) + - **FIX**(example): resolve WebFinger after setup, and dump the plan on a 500. ([3406f070](https://github.com/Bdaya-Dev/oidc/commit/3406f0705e73bc6afe8d4be553d483259d8ab0f8)) + - **FIX**(oidc_android): forward Map-valued raw intent extras instead of dropping them. ([7ffda6a7](https://github.com/Bdaya-Dev/oidc/commit/7ffda6a74f07aeaba3a077a1d879a615b3dfbe77)) + - **FEAT**(oidc_core): manager-level dynamic client registration (RFC 7591). ([fe4b4c4e](https://github.com/Bdaya-Dev/oidc/commit/fe4b4c4e30b1c97db06407dc32f7d4b1106e21ca)) + - **FEAT**(oidc_core): implement WebFinger issuer discovery (OIDC Discovery §2, RFC 7033). ([213657b8](https://github.com/Bdaya-Dev/oidc/commit/213657b8dad13d62a1c58d069c801cf26b995439)) + - **FEAT**(oidc_core,oidc_web_core): bound the interactive web flow with flowTimeoutSeconds. ([7282efa0](https://github.com/Bdaya-Dev/oidc/commit/7282efa09d406fa3eb012688fbbc091836d8ba2e)) + - **BREAKING** **FIX**(oidc_core): the REGISTERED token_endpoint_auth_method wins over the caller's. ([262bdfd4](https://github.com/Bdaya-Dev/oidc/commit/262bdfd4f0c1fac0f29c65be23bb3c061adb8310)) + - **BREAKING** **FIX**(oidc_android): report the options the Auth Tab path cannot apply. ([8b7226ce](https://github.com/Bdaya-Dev/oidc/commit/8b7226ced748e953e8b3ea95ba3c711d8976dd8d)) + +#### `oidc_android` - `v3.0.0` + + - **FIX**(oidc_web_core): surface the flow timeout as OidcException, not TimeoutException. ([be6a5316](https://github.com/Bdaya-Dev/oidc/commit/be6a5316df6cc83ee90807c4f88f4d37d5cd144d)) + - **FIX**(oidc_android): forward Map-valued raw intent extras instead of dropping them. ([7ffda6a7](https://github.com/Bdaya-Dev/oidc/commit/7ffda6a74f07aeaba3a077a1d879a615b3dfbe77)) + - **FIX**(oidc_android): dismiss the Custom Tab after the intent-filter redirect. ([34a83693](https://github.com/Bdaya-Dev/oidc/commit/34a836933563e3fe37dc7121b87272676554a331)) + - **DOCS**(test): state the wiring guard's single-layer scope. ([e25e04b8](https://github.com/Bdaya-Dev/oidc/commit/e25e04b89711cd526919f73ed7c9a8faf5c386cb)) + - **BREAKING** **FIX**(oidc_android): report the options the Auth Tab path cannot apply. ([8b7226ce](https://github.com/Bdaya-Dev/oidc/commit/8b7226ced748e953e8b3ea95ba3c711d8976dd8d)) + - **BREAKING** **FIX**(oidc_android): apply the documented Custom Tabs options natively. ([9e83a10b](https://github.com/Bdaya-Dev/oidc/commit/9e83a10ba732611ec4ef9d11c8cbe3a68ca8a7b9)) + +#### `oidc_core` - `v3.0.0` + + - **FIX**(oidc_core): allow valid multi-audience ID tokens by default ([#441](https://github.com/Bdaya-Dev/oidc/issues/441)). ([f50cd755](https://github.com/Bdaya-Dev/oidc/commit/f50cd7551c5827f84368ed59f159d30c217948ec)) + - **FIX**(oidc_core): log offline userinfo failures as WARNING, not SEVERE ([#440](https://github.com/Bdaya-Dev/oidc/issues/440)). ([c3b01b73](https://github.com/Bdaya-Dev/oidc/commit/c3b01b73f78737eaa57e819cb86682dd94133819)) + - **FIX**(oidc_core): reject `code`-only in loginHybridFlow, and stop overclaiming front-channel safety. ([cedaf1af](https://github.com/Bdaya-Dev/oidc/commit/cedaf1af4de3ac496b7286fb791600e500120e62)) + - **FEAT**(oidc_core): manager-level dynamic client registration (RFC 7591). ([fe4b4c4e](https://github.com/Bdaya-Dev/oidc/commit/fe4b4c4e30b1c97db06407dc32f7d4b1106e21ca)) + - **FEAT**(oidc_core): implement WebFinger issuer discovery (OIDC Discovery §2, RFC 7033). ([213657b8](https://github.com/Bdaya-Dev/oidc/commit/213657b8dad13d62a1c58d069c801cf26b995439)) + - **FEAT**(oidc_core): add loginHybridFlow (OIDC Core §3.3). ([9afa3159](https://github.com/Bdaya-Dev/oidc/commit/9afa31591756c97b54527b7edcd2b094e0273c42)) + - **FEAT**(oidc_core,oidc_web_core): bound the interactive web flow with flowTimeoutSeconds. ([7282efa0](https://github.com/Bdaya-Dev/oidc/commit/7282efa09d406fa3eb012688fbbc091836d8ba2e)) + - **BREAKING** **FIX**(oidc_core): never take the PKCE code_verifier from the authorization response ([#404](https://github.com/Bdaya-Dev/oidc/issues/404)). ([9a6006e0](https://github.com/Bdaya-Dev/oidc/commit/9a6006e089d49deaa79e73218a7c757dd2ed1a88)) + - **BREAKING** **FIX**(oidc_core): drop the legacy in-payload code_verifier fallback ([#404](https://github.com/Bdaya-Dev/oidc/issues/404)). ([a0c991c5](https://github.com/Bdaya-Dev/oidc/commit/a0c991c56684fa48469487728516bfad38fbb681)) + - **BREAKING** **FIX**(oidc_core): the REGISTERED token_endpoint_auth_method wins over the caller's. ([262bdfd4](https://github.com/Bdaya-Dev/oidc/commit/262bdfd4f0c1fac0f29c65be23bb3c061adb8310)) + - **BREAKING** **FIX**(oidc_android): report the options the Auth Tab path cannot apply. ([8b7226ce](https://github.com/Bdaya-Dev/oidc/commit/8b7226ced748e953e8b3ea95ba3c711d8976dd8d)) + +#### `oidc_desktop` - `v1.1.0` + + - **FEAT**(oidc_loopback_listener): capture form_post bodies and fragments. ([3bc512c3](https://github.com/Bdaya-Dev/oidc/commit/3bc512c3b0fa9377635083707ecc5d7f7886fdea)) + +#### `oidc_loopback_listener` - `v1.1.0` + + - **FEAT**(oidc_loopback_listener): capture form_post bodies and fragments. ([3bc512c3](https://github.com/Bdaya-Dev/oidc/commit/3bc512c3b0fa9377635083707ecc5d7f7886fdea)) + +#### `oidc_web_core` - `v1.2.0` + + - **FIX**(oidc_web_core): say what the auth window did when a flow times out. ([63532065](https://github.com/Bdaya-Dev/oidc/commit/635320654250d2338fde2cade4ea93b88f7fbb5a)) + - **FIX**(example): web loses Hybrid/Implicit to a timeout budget, not a fragment bug. ([624a502e](https://github.com/Bdaya-Dev/oidc/commit/624a502e04ceae7ac26ec05f3d523fe1a870bd7a)) + - **FIX**(oidc_web_core): surface the flow timeout as OidcException, not TimeoutException. ([be6a5316](https://github.com/Bdaya-Dev/oidc/commit/be6a5316df6cc83ee90807c4f88f4d37d5cd144d)) + - **FEAT**(oidc_core,oidc_web_core): bound the interactive web flow with flowTimeoutSeconds. ([7282efa0](https://github.com/Bdaya-Dev/oidc/commit/7282efa09d406fa3eb012688fbbc091836d8ba2e)) + + +## 2026-07-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc_android` - `v2.0.1`](#oidc_android---v201) + - [`oidc` - `v3.0.1`](#oidc---v301) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc` - `v3.0.1` + +--- + +#### `oidc_android` - `v2.0.1` + + - **DOCS**(oidc_android): state the minSdk 23 requirement, and stop instructing 21 ([#427](https://github.com/Bdaya-Dev/oidc/issues/427)). ([1f210d0a](https://github.com/Bdaya-Dev/oidc/commit/1f210d0a36843dd0aa5080be499ecbafa0347c35)) + + +## 2026-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v3.0.0`](#oidc---v300) + - [`oidc_android` - `v2.0.0`](#oidc_android---v200) + +Packages with other changes: + + - [`oidc_core` - `v2.1.0`](#oidc_core---v210) + - [`oidc_cli` - `v1.0.2`](#oidc_cli---v102) + - [`oidc_default_store` - `v1.1.1`](#oidc_default_store---v111) + - [`oidc_desktop` - `v1.0.2`](#oidc_desktop---v102) + - [`oidc_darwin` - `v1.1.1`](#oidc_darwin---v111) + - [`oidc_linux` - `v1.0.2`](#oidc_linux---v102) + - [`oidc_platform_interface` - `v1.0.2`](#oidc_platform_interface---v102) + - [`oidc_web` - `v1.0.2`](#oidc_web---v102) + - [`oidc_web_core` - `v1.1.1`](#oidc_web_core---v111) + - [`oidc_windows` - `v1.0.2`](#oidc_windows---v102) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_cli` - `v1.0.2` + - `oidc_default_store` - `v1.1.1` + - `oidc_desktop` - `v1.0.2` + - `oidc_darwin` - `v1.1.1` + - `oidc_linux` - `v1.0.2` + - `oidc_platform_interface` - `v1.0.2` + - `oidc_web` - `v1.0.2` + - `oidc_web_core` - `v1.1.1` + - `oidc_windows` - `v1.0.2` + +--- + +#### `oidc` - `v3.0.0` + + - **BREAKING** **FIX**(oidc_android): restore the redirect receiver as a fallback capture path ([#418](https://github.com/Bdaya-Dev/oidc/issues/418)) ([#419](https://github.com/Bdaya-Dev/oidc/issues/419)). ([8df8bc61](https://github.com/Bdaya-Dev/oidc/commit/8df8bc617fb91347eac9f32fcffb7ec88f95328a)) + +#### `oidc_android` - `v2.0.0` + + - **BREAKING** **FIX**(oidc_android): restore the redirect receiver as a fallback capture path ([#418](https://github.com/Bdaya-Dev/oidc/issues/418)) ([#419](https://github.com/Bdaya-Dev/oidc/issues/419)). ([8df8bc61](https://github.com/Bdaya-Dev/oidc/commit/8df8bc617fb91347eac9f32fcffb7ec88f95328a)) + +#### `oidc_core` - `v2.1.0` + + - **FEAT**(oidc_core): silent-acquisition API at the manager/app boundary ([#421](https://github.com/Bdaya-Dev/oidc/issues/421), [#422](https://github.com/Bdaya-Dev/oidc/issues/422), [#423](https://github.com/Bdaya-Dev/oidc/issues/423), [#424](https://github.com/Bdaya-Dev/oidc/issues/424)) ([#425](https://github.com/Bdaya-Dev/oidc/issues/425)). ([97ac60e6](https://github.com/Bdaya-Dev/oidc/commit/97ac60e697ef3f82dfec32e6d1a3b143440a61b9)) + + +## 2026-07-14 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v2.0.0`](#oidc---v200) + - [`oidc_core` - `v2.0.0`](#oidc_core---v200) + +Packages with other changes: + + - [`oidc_cli` - `v1.0.1`](#oidc_cli---v101) + - [`oidc_darwin` - `v1.1.0`](#oidc_darwin---v110) + - [`oidc_default_store` - `v1.1.0`](#oidc_default_store---v110) + - [`oidc_web_core` - `v1.1.0`](#oidc_web_core---v110) + - [`oidc_android` - `v1.0.1`](#oidc_android---v101) + - [`oidc_desktop` - `v1.0.1`](#oidc_desktop---v101) + - [`oidc_linux` - `v1.0.1`](#oidc_linux---v101) + - [`oidc_platform_interface` - `v1.0.1`](#oidc_platform_interface---v101) + - [`oidc_web` - `v1.0.1`](#oidc_web---v101) + - [`oidc_windows` - `v1.0.1`](#oidc_windows---v101) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_android` - `v1.0.1` + - `oidc_desktop` - `v1.0.1` + - `oidc_linux` - `v1.0.1` + - `oidc_platform_interface` - `v1.0.1` + - `oidc_web` - `v1.0.1` + - `oidc_windows` - `v1.0.1` + +--- + +#### `oidc` - `v2.0.0` + + - **FEAT**(oidc_web_core): status-driven redirect.html + app-ack wire ([#116](https://github.com/Bdaya-Dev/oidc/issues/116), [#227](https://github.com/Bdaya-Dev/oidc/issues/227), [#256](https://github.com/Bdaya-Dev/oidc/issues/256)) ([#397](https://github.com/Bdaya-Dev/oidc/issues/397)). ([3ea11929](https://github.com/Bdaya-Dev/oidc/commit/3ea119290f1007d62b9ecb8ffcaeb3a771f7df9f)) + - **FEAT**(oidc_core): emit OidcTokenRefreshFailedEvent on refresh failures; clamp the expiring-refire loop ([#392](https://github.com/Bdaya-Dev/oidc/issues/392)). ([0b581dff](https://github.com/Bdaya-Dev/oidc/commit/0b581dffcc9761d9ee112aa0df6856a610c10ac5)) + - **BREAKING** **FEAT**(oidc_core): cache-first init as the new default, discovery TTL cache, loaded-token validity controls ([#399](https://github.com/Bdaya-Dev/oidc/issues/399)). ([de3e2287](https://github.com/Bdaya-Dev/oidc/commit/de3e2287ec0649562e3df426f42a628168417378)) + +#### `oidc_core` - `v2.0.0` + + - **FIX**(oidc_core): protect the PKCE code_verifier at rest ([#324](https://github.com/Bdaya-Dev/oidc/issues/324) item 20) ([#402](https://github.com/Bdaya-Dev/oidc/issues/402)). ([32f4cbb3](https://github.com/Bdaya-Dev/oidc/commit/32f4cbb341e154d12ae687c6b2a8b23565779a20)) + - **FIX**(oidc_core): route deleteClientConfiguration through typed error parsing ([#400](https://github.com/Bdaya-Dev/oidc/issues/400)). ([bd250ff5](https://github.com/Bdaya-Dev/oidc/commit/bd250ff58c3cd13a5bee5b2377edaf017b60c04a)) + - **FIX**(oidc_core): honor expectedIssuer in validateUser (Entra multi-tenant) ([#389](https://github.com/Bdaya-Dev/oidc/issues/389)). ([4abc4e74](https://github.com/Bdaya-Dev/oidc/commit/4abc4e74282968cdf51ccccedfe99bff8f678e86)) + - **FIX**(oidc_core): send dpop_jkt on the direct authorization request ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)) ([#391](https://github.com/Bdaya-Dev/oidc/issues/391)). ([c5e7420a](https://github.com/Bdaya-Dev/oidc/commit/c5e7420a1db47bd00217e5f9426666cc965c8b4f)) + - **FEAT**(oidc_core): mTLS RFC 8705 phase 1 core surface ([#386](https://github.com/Bdaya-Dev/oidc/issues/386)) ([#412](https://github.com/Bdaya-Dev/oidc/issues/412)). ([f9b1446b](https://github.com/Bdaya-Dev/oidc/commit/f9b1446b693f86666a64c190fe6a0d2ae9b7cf72)) + - **FEAT**(oidc_core): DCR integration helpers ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)) ([#413](https://github.com/Bdaya-Dev/oidc/issues/413)). ([c07e881a](https://github.com/Bdaya-Dev/oidc/commit/c07e881a01a069a927e0cfab2c3ce70c4f0ebd42)) + - **FEAT**(oidc_core): react to a UserInfo 401 during session resume ([#302](https://github.com/Bdaya-Dev/oidc/issues/302)) ([#403](https://github.com/Bdaya-Dev/oidc/issues/403)). ([933e0ed7](https://github.com/Bdaya-Dev/oidc/commit/933e0ed7fd67d341cbf8a5ef43f23ba6de1c206b)) + - **FEAT**(oidc_darwin): add macOS loopback system-browser navigation mode ([#124](https://github.com/Bdaya-Dev/oidc/issues/124)) ([#398](https://github.com/Bdaya-Dev/oidc/issues/398)). ([a8d758f7](https://github.com/Bdaya-Dev/oidc/commit/a8d758f74fd86b93e74fabe6c561235ca4ee3343)) + - **FEAT**(oidc_core): emit OidcTokenRefreshFailedEvent on refresh failures; clamp the expiring-refire loop ([#392](https://github.com/Bdaya-Dev/oidc/issues/392)). ([0b581dff](https://github.com/Bdaya-Dev/oidc/commit/0b581dffcc9761d9ee112aa0df6856a610c10ac5)) + - **BREAKING** **FEAT**(oidc_core): cache-first init as the new default, discovery TTL cache, loaded-token validity controls ([#399](https://github.com/Bdaya-Dev/oidc/issues/399)). ([de3e2287](https://github.com/Bdaya-Dev/oidc/commit/de3e2287ec0649562e3df426f42a628168417378)) + +#### `oidc_cli` - `v1.0.1` + + - **FIX**(oidc_cli): bound the interactive loopback wait with flowTimeoutSeconds ([#369](https://github.com/Bdaya-Dev/oidc/issues/369)) ([#388](https://github.com/Bdaya-Dev/oidc/issues/388)). ([5e9cf93a](https://github.com/Bdaya-Dev/oidc/commit/5e9cf93a5062cd875096ff232eb64c4d3a42ac05)) + +#### `oidc_darwin` - `v1.1.0` + + - **FEAT**(oidc_darwin): add macOS loopback system-browser navigation mode ([#124](https://github.com/Bdaya-Dev/oidc/issues/124)) ([#398](https://github.com/Bdaya-Dev/oidc/issues/398)). ([a8d758f7](https://github.com/Bdaya-Dev/oidc/commit/a8d758f74fd86b93e74fabe6c561235ca4ee3343)) + +#### `oidc_default_store` - `v1.1.0` + + - **FEAT**(oidc_default_store): support the new SharedPreferencesAsync API ([#301](https://github.com/Bdaya-Dev/oidc/issues/301)) ([#390](https://github.com/Bdaya-Dev/oidc/issues/390)). ([0045f623](https://github.com/Bdaya-Dev/oidc/commit/0045f6231cd5e5267520c69342f89e420241fffe)) + +#### `oidc_web_core` - `v1.1.0` + + - **FIX**(oidc_core): protect the PKCE code_verifier at rest ([#324](https://github.com/Bdaya-Dev/oidc/issues/324) item 20) ([#402](https://github.com/Bdaya-Dev/oidc/issues/402)). ([32f4cbb3](https://github.com/Bdaya-Dev/oidc/commit/32f4cbb341e154d12ae687c6b2a8b23565779a20)) + - **FEAT**(oidc_web_core): status-driven redirect.html + app-ack wire ([#116](https://github.com/Bdaya-Dev/oidc/issues/116), [#227](https://github.com/Bdaya-Dev/oidc/issues/227), [#256](https://github.com/Bdaya-Dev/oidc/issues/256)) ([#397](https://github.com/Bdaya-Dev/oidc/issues/397)). ([3ea11929](https://github.com/Bdaya-Dev/oidc/commit/3ea119290f1007d62b9ecb8ffcaeb3a771f7df9f)) + + +## 2026-07-13 + +### Changes + +--- + +Packages with breaking changes: + + - [`crypto_keys_plus` - `v1.0.0`](#crypto_keys_plus---v100) + - [`x509_plus` - `v1.0.0`](#x509_plus---v100) + - [`jose_plus` - `v1.0.0`](#jose_plus---v100) + - [`oidc` - `v1.0.0`](#oidc---v100) + - [`oidc_android` - `v1.0.0`](#oidc_android---v100) + - [`oidc_cli` - `v1.0.0`](#oidc_cli---v100) + - [`oidc_core` - `v1.0.0`](#oidc_core---v100) + - [`oidc_darwin` - `v1.0.0`](#oidc_darwin---v100) + - [`oidc_desktop` - `v1.0.0`](#oidc_desktop---v100) + - [`oidc_platform_interface` - `v1.0.0`](#oidc_platform_interface---v100) + - [`oidc_web_core` - `v1.0.0`](#oidc_web_core---v100) + +Packages with other changes: + + - [`oidc_default_store` - `v1.0.0`](#oidc_default_store---v100) + - [`oidc_linux` - `v1.0.0`](#oidc_linux---v100) + - [`oidc_loopback_listener` - `v1.0.0`](#oidc_loopback_listener---v100) + - [`oidc_web` - `v1.0.0`](#oidc_web---v100) + - [`oidc_windows` - `v1.0.0`](#oidc_windows---v100) + +--- + +#### `crypto_keys_plus` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(crypto_keys_plus): resolve pub.dev publish dry-run warnings. ([5bee9a89](https://github.com/Bdaya-Dev/oidc/commit/5bee9a8954e4fc5412f20db826aa7fe053eebabb)) + - **FEAT**(crypto): RSASSA-PSS (PS256/384/512) + EdDSA/Ed25519. ([6c5c6741](https://github.com/Bdaya-Dev/oidc/commit/6c5c674149fe0504207c95aefbda7fedfca72ae9)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + +#### `x509_plus` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(ci): stop swallowing web test failures; pin dart:io fixture tests to vm ([#363](https://github.com/Bdaya-Dev/oidc/issues/363)). ([6102d835](https://github.com/Bdaya-Dev/oidc/commit/6102d8353b0d330f888ea8b8e9b4c52937be2236)) + - **FIX**(release): add package-name entrypoints for jose_plus and x509_plus. ([4bbcb7f3](https://github.com/Bdaya-Dev/oidc/commit/4bbcb7f3df69cf393be29bd5997f49c15add7ff4)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + +#### `jose_plus` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(ci): stop swallowing web test failures; pin dart:io fixture tests to vm ([#363](https://github.com/Bdaya-Dev/oidc/issues/363)). ([6102d835](https://github.com/Bdaya-Dev/oidc/commit/6102d8353b0d330f888ea8b8e9b4c52937be2236)) + - **FIX**(release): add package-name entrypoints for jose_plus and x509_plus. ([4bbcb7f3](https://github.com/Bdaya-Dev/oidc/commit/4bbcb7f3df69cf393be29bd5997f49c15add7ff4)) + - **FIX**(jose_plus): normalize crypto_keys_plus/x509_plus constraints to caret syntax. ([d0a1d55d](https://github.com/Bdaya-Dev/oidc/commit/d0a1d55da2507e2f12b1244dcedaa1481fad1aed)) + - **FEAT**(crypto): RSASSA-PSS (PS256/384/512) + EdDSA/Ed25519. ([6c5c6741](https://github.com/Bdaya-Dev/oidc/commit/6c5c674149fe0504207c95aefbda7fedfca72ae9)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + +#### `oidc` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_darwin): implement flowTimeoutSeconds for the Apple ASWebAuthenticationSession flow. ([482f0186](https://github.com/Bdaya-Dev/oidc/commit/482f0186b8cdb37d309118de187e1c8496555d9a)) + - **FIX**(example): add patrol RunnerUITests UI-testing target for iOS. ([21b740f6](https://github.com/Bdaya-Dev/oidc/commit/21b740f645be41a0792299660d2a96db26fb378a)) + - **FIX**(example): wrap patrol conformance placeholder with SharedValue.wrapApp. ([831df91c](https://github.com/Bdaya-Dev/oidc/commit/831df91c7f041a96af10bf1c80ef92f49d45a2c8)) + - **FIX**(example): use FlutterFragmentActivity for Auth Tab (ComponentActivity). ([e6e9be47](https://github.com/Bdaya-Dev/oidc/commit/e6e9be47683d80897a6dce6f92433408c89155f4)) + - **FIX**: pre-v1 correctness — certification claim, license, Android queries, honest native option docs. ([3b8ef447](https://github.com/Bdaya-Dev/oidc/commit/3b8ef447f2a1c0af68ec6711c77d435531fb827f)) + - **FIX**(spm,native): SwiftPM layout migration + conformance fixes; fix Android build. ([47f7bd25](https://github.com/Bdaya-Dev/oidc/commit/47f7bd25feb5d4b7a2e3db67165a44ecdf4dae29)) + - **FIX**(native): harden iOS threading, simplify Android redirect to one-line setup. ([a7553f32](https://github.com/Bdaya-Dev/oidc/commit/a7553f326c1d67ac2bd057b0864688d73df24661)) + - **FIX**: handle refresh responses without id_token. ([4af363be](https://github.com/Bdaya-Dev/oidc/commit/4af363bed630d18394b28af1664f334aca3df8d9)) + - **FEAT**(observability): native browser events via the existing OidcEvent stream (Phase 3). ([91d1f5bd](https://github.com/Bdaya-Dev/oidc/commit/91d1f5bdfa1526aec170474181ec71ad1bf38c59)) + - **BREAKING** **REFACTOR**: remove rxdart; adopt bdaya_shared_value ^5.0.0. ([0d65d7fd](https://github.com/Bdaya-Dev/oidc/commit/0d65d7fde062e2db7ffbdd31a47735c59954045a)) + - **BREAKING** **FEAT**(oidc_core): remove the strictJwtVerification fail-open opt-out. ([ee2146f9](https://github.com/Bdaya-Dev/oidc/commit/ee2146f9fa966c352a7c751673550bdcb5e7c0a5)) + - **BREAKING** **FEAT**(oidc_android): switch to Auth Tab only, remove Custom Tabs path. ([05bf0181](https://github.com/Bdaya-Dev/oidc/commit/05bf01811e299c472d49efb303fb657c939f0bd4)) + - **BREAKING** **FEAT**(oidc_android): add flowTimeoutSeconds to fix headless CI hang. ([01c844f5](https://github.com/Bdaya-Dev/oidc/commit/01c844f5bd98a3d983b9e50f9fa2192ed7013e50)) + - **BREAKING** **FEAT**: merge oidc_ios + oidc_macos into a unified oidc_darwin plugin. ([db73858e](https://github.com/Bdaya-Dev/oidc/commit/db73858e71b3b869326867b05b9d1ead3629acb9)) + - **BREAKING** **FEAT**(options): redesign native options API (v1 clean break, no AppAuth framing). ([a78954fe](https://github.com/Bdaya-Dev/oidc/commit/a78954feb4c4c6dfb0abc15f7e0a308be74d4e95)) + - **BREAKING** **FEAT**(oidc_macos): first-party ASWebAuthenticationSession; drop flutter_appauth. ([dc13f411](https://github.com/Bdaya-Dev/oidc/commit/dc13f411a3bfca4572a0f0e8fea2705365314d3c)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + - **BREAKING** **CHORE**: v1 dependency upgrade + drop the pigeon global-tool wrapper. ([45b62a3e](https://github.com/Bdaya-Dev/oidc/commit/45b62a3ef3f5b42cfb590111c9e37e144bbc11b0)) + +#### `oidc_android` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(example): use FlutterFragmentActivity for Auth Tab (ComponentActivity). ([e6e9be47](https://github.com/Bdaya-Dev/oidc/commit/e6e9be47683d80897a6dce6f92433408c89155f4)) + - **FIX**(oidc_android): remove duplicate mainHandler declaration. ([ae0f70c2](https://github.com/Bdaya-Dev/oidc/commit/ae0f70c20b4c011f35365be44673713622eeacc9)) + - **FIX**(oidc_android): use flowId self-check instead of Handler.removeCallbacks. ([c8f585a3](https://github.com/Bdaya-Dev/oidc/commit/c8f585a36912af3ef379fdb88eb046035b8d22dd)) + - **FIX**: pre-v1 correctness — certification claim, license, Android queries, honest native option docs. ([3b8ef447](https://github.com/Bdaya-Dev/oidc/commit/3b8ef447f2a1c0af68ec6711c77d435531fb827f)) + - **FIX**(native): harden iOS threading, simplify Android redirect to one-line setup. ([a7553f32](https://github.com/Bdaya-Dev/oidc/commit/a7553f326c1d67ac2bd057b0864688d73df24661)) + - **FEAT**(android): Auth Tab redirect-capture path (Phase 4, opt-in). ([0b80aaf2](https://github.com/Bdaya-Dev/oidc/commit/0b80aaf2a253f31f7b4fee62e91a0f10fdd1fa25)) + - **FEAT**(observability): native browser events via the existing OidcEvent stream (Phase 3). ([91d1f5bd](https://github.com/Bdaya-Dev/oidc/commit/91d1f5bdfa1526aec170474181ec71ad1bf38c59)) + - **FEAT**(android): apply typed Custom Tabs options natively (Phase 1). ([10e903eb](https://github.com/Bdaya-Dev/oidc/commit/10e903ebcbdb7fa8cb33c7f2f4d30b58db26d33e)) + - **BREAKING** **REFACTOR**: remove rxdart; adopt bdaya_shared_value ^5.0.0. ([0d65d7fd](https://github.com/Bdaya-Dev/oidc/commit/0d65d7fde062e2db7ffbdd31a47735c59954045a)) + - **BREAKING** **FEAT**(oidc_android): switch to Auth Tab only, remove Custom Tabs path. ([05bf0181](https://github.com/Bdaya-Dev/oidc/commit/05bf01811e299c472d49efb303fb657c939f0bd4)) + - **BREAKING** **FEAT**(oidc_android): add flowTimeoutSeconds to fix headless CI hang. ([01c844f5](https://github.com/Bdaya-Dev/oidc/commit/01c844f5bd98a3d983b9e50f9fa2192ed7013e50)) + - **BREAKING** **FEAT**: merge oidc_ios + oidc_macos into a unified oidc_darwin plugin. ([db73858e](https://github.com/Bdaya-Dev/oidc/commit/db73858e71b3b869326867b05b9d1ead3629acb9)) + - **BREAKING** **FEAT**(native): migrate native transport to Pigeon + automate codegen. ([fc7606f3](https://github.com/Bdaya-Dev/oidc/commit/fc7606f3329cc493281a438ff76482436b018709)) + - **BREAKING** **FEAT**(oidc_android): replace flutter_appauth with first-party Custom Tabs auth. ([ddd64296](https://github.com/Bdaya-Dev/oidc/commit/ddd642968ca95cc57f0745d3ec59b5d9bc3c290f)) + +#### `oidc_cli` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FEAT**(storage): harden token storage at rest (RFC 9700 §4.9.3). ([76111b4a](https://github.com/Bdaya-Dev/oidc/commit/76111b4a1022a140c0b510e088becaed54835c0f)) + - **BREAKING** **CHORE**: v1 dependency upgrade + drop the pigeon global-tool wrapper. ([45b62a3e](https://github.com/Bdaya-Dev/oidc/commit/45b62a3ef3f5b42cfb590111c9e37e144bbc11b0)) + +#### `oidc_core` - `v1.0.0` + + - **FIX**(oidc_core): also ungate AUTO refresh-on-expiry from grant_types_supported ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([7e543b98](https://github.com/Bdaya-Dev/oidc/commit/7e543b98262334b17ebadce8c5761a6170a86025)) + - **FIX**: update packages/oidc_core/lib/src/managers/user_manager_base.dart. ([d62828c0](https://github.com/Bdaya-Dev/oidc/commit/d62828c06efc3fc9257d54a8818f189eb7e99b22)) + - **FIX**(oidc_core): kid-miss refetch on the cacheStore-less verification path. ([8eecbe28](https://github.com/Bdaya-Dev/oidc/commit/8eecbe28a99e5b0b0eba31c034ca8256c894d79f)) + - **FIX**(oidc_core): refetch JWKS on unknown kid with per-issuer cooldown. ([badeba7b](https://github.com/Bdaya-Dev/oidc/commit/badeba7bcfdea7c22f6f5b0707894b5dd5b741dd)) + - **FIX**(oidc_core): single-location client auth on token exchange and introspection (RFC 6749 §2.3). ([36f67e39](https://github.com/Bdaya-Dev/oidc/commit/36f67e39f8b02a614059325254204c047def938e)) + - **FIX**(oidc_core): validate RFC 9207 iss on authorization error responses. ([5916b65e](https://github.com/Bdaya-Dev/oidc/commit/5916b65e4a7ac58dae13ac5990d44e4b2f15b056)) + - **FIX**(oidc_core): always send id_token_hint on RP-initiated logout. ([434af9ab](https://github.com/Bdaya-Dev/oidc/commit/434af9ab0a02e55c189bceacc72767cac7076ede)) + - **FIX**(oidc_core): reject UserInfo responses missing sub (OIDC Core §5.3.2). ([f133c5b2](https://github.com/Bdaya-Dev/oidc/commit/f133c5b2a44dfd684379945244e70293e2663ac9)) + - **FIX**(oidc_core): send client auth in exactly one location on refresh (RFC 6749 §2.3). ([f6bf79a8](https://github.com/Bdaya-Dev/oidc/commit/f6bf79a866793750961d902dbdf87a5e1423e1fa)) + - **FIX**(oidc_core): percent-encode client_secret_basic credentials (RFC 6749 §2.3.1). ([94964778](https://github.com/Bdaya-Dev/oidc/commit/949647783dcb05c7d1658ce47e19c88b4a461e1f)) + - **FIX**(oidc_darwin): implement flowTimeoutSeconds for the Apple ASWebAuthenticationSession flow. ([482f0186](https://github.com/Bdaya-Dev/oidc/commit/482f0186b8cdb37d309118de187e1c8496555d9a)) + - **FIX**: handle whitespace-only payloads. ([2751b841](https://github.com/Bdaya-Dev/oidc/commit/2751b841c8cef1f06c9e4ced7cf6df2dc5a6fd75)) + - **FIX**(oidc_core): strip terminating slash when building well-known URL ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([975d446f](https://github.com/Bdaya-Dev/oidc/commit/975d446f4fa08478e9434463c55c917bbd3a39f8)) + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_core): close 7 P0 spec-audit findings ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([60907e96](https://github.com/Bdaya-Dev/oidc/commit/60907e96f33dce8bf961b26ed43cec20f56e595e)) + - **FIX**: handle refresh responses without id_token. ([4af363be](https://github.com/Bdaya-Dev/oidc/commit/4af363bed630d18394b28af1664f334aca3df8d9)) + - **FIX**(oidc_core): restore web/WASM compatibility for offline error handling. ([0c2e894a](https://github.com/Bdaya-Dev/oidc/commit/0c2e894a232514f0b6bfa1e7c3e8414756f1027d)) + - **FIX**(core): harden DPoP thumbprint + JARM verification (adversarial-review fixes). ([69ac51aa](https://github.com/Bdaya-Dev/oidc/commit/69ac51aa16e602eab00765590fc0f221a0b213dd)) + - **FIX**(core): DPoP — pad short EC coordinates (RFC 7638 jkt correctness). ([bd5568e7](https://github.com/Bdaya-Dev/oidc/commit/bd5568e73650e4ecdd8623141347300cbcef2b93)) + - **FIX**(core): update device-code flow test for the fail-closed JWT default. ([fa333f89](https://github.com/Bdaya-Dev/oidc/commit/fa333f899914ba47fc05bfb312de414cbb1df114)) + - **FIX**: handle empty response. ([03179667](https://github.com/Bdaya-Dev/oidc/commit/031796675c1b27cd8d32a036bd56dfcb9a9fedad)) + - **FIX**(oidc_core): harden ID-token/UserInfo validation (P0 spec-compliance). ([1e7cfee9](https://github.com/Bdaya-Dev/oidc/commit/1e7cfee91f0de9391fea699300cac079f874843e)) + - **FIX**: pre-v1 correctness — certification claim, license, Android queries, honest native option docs. ([3b8ef447](https://github.com/Bdaya-Dev/oidc/commit/3b8ef447f2a1c0af68ec6711c77d435531fb827f)) + - **FEAT**(oidc_core): deferred audit best-practice hardening (alg-pin, signed-userinfo, issuer, back-channel logout). ([7259290d](https://github.com/Bdaya-Dev/oidc/commit/7259290d48db2e5b59299b7c78bb99e97bf325b8)) + - **FEAT**(android): apply typed Custom Tabs options natively (Phase 1). ([10e903eb](https://github.com/Bdaya-Dev/oidc/commit/10e903ebcbdb7fa8cb33c7f2f4d30b58db26d33e)) + - **FEAT**(oidc_core): enforce RFC 9207 iss require-when-advertised + error-path ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([3f324712](https://github.com/Bdaya-Dev/oidc/commit/3f324712f978c0c5af465261f9b9e3f300d6d398)) + - **FEAT**(core): JAR signed request objects + JARM signed responses (RFC 9101 / JARM). ([19480489](https://github.com/Bdaya-Dev/oidc/commit/19480489e61419472ba8642fd75332fda7f1349a)) + - **FEAT**(core): validate the Hybrid-flow front-channel id_token (OIDC Core §3.3.2). ([8dbc582b](https://github.com/Bdaya-Dev/oidc/commit/8dbc582ba4160fdef2e85287449cff7e38ae5f01)) + - **FEAT**(core): Pushed Authorization Requests (PAR, RFC 9126) — endpoint + model. ([6eb09f8a](https://github.com/Bdaya-Dev/oidc/commit/6eb09f8a42e6033606eba6b7cd6805fdc9aeb64a)) + - **FEAT**(core): Token Introspection (RFC 7662) + step-up challenge parsing (RFC 9470). ([ab0cdff0](https://github.com/Bdaya-Dev/oidc/commit/ab0cdff07cda9b0cf54c5d90be5b3fad14bfdfd6)) + - **FEAT**(core): Resource Indicators (RFC 8707) + Token Exchange (RFC 8693). ([43d8d664](https://github.com/Bdaya-Dev/oidc/commit/43d8d664ff2db657319830db73a3cbcc03a50b00)) + - **FEAT**(core): DPoP resource-endpoint nonce retry (RFC 9449 §9). ([1d733790](https://github.com/Bdaya-Dev/oidc/commit/1d733790d2def393b57ebc2b3702ffe9c9c480dc)) + - **FEAT**(core): validate c_hash + auth_time/max_age (OIDC Core §3.3.2.11 / §3.1.2.1). ([93c81845](https://github.com/Bdaya-Dev/oidc/commit/93c8184559b01062b01185870bd59bc5d6eeb576)) + - **FEAT**(core): DPoP phase 3 — dpop_jkt auth-code binding + UserInfo DPoP scheme. ([d28b736f](https://github.com/Bdaya-Dev/oidc/commit/d28b736f0fff30dca43adad9fe43d4a4648faf24)) + - **FEAT**(core): DPoP phase 2 — use_dpop_nonce retry (centralized in the token endpoint). ([bc059b27](https://github.com/Bdaya-Dev/oidc/commit/bc059b2729f4dc81339325b7e9bcf148aeb8ed68)) + - **FEAT**(core): mint private_key_jwt / client_secret_jwt client assertions. ([c7043fcc](https://github.com/Bdaya-Dev/oidc/commit/c7043fccc2a452b3a79db6506d09b0d97f2e0890)) + - **FEAT**(storage): harden token storage at rest (RFC 9700 §4.9.3). ([76111b4a](https://github.com/Bdaya-Dev/oidc/commit/76111b4a1022a140c0b510e088becaed54835c0f)) + - **FEAT**(core): DPoP phase 1b — attach proofs to token-endpoint requests. ([c0207476](https://github.com/Bdaya-Dev/oidc/commit/c0207476c469f1ce14c0b9828af536219325094d)) + - **FEAT**(oidc): batch-2 audit hardening (loopback timeout, auth_time/max_age, resilient discovery parse, unverified-userinfo guard). ([d7f5965a](https://github.com/Bdaya-Dev/oidc/commit/d7f5965ac4ecbad0d7a79849e0e89d729a736169)) + - **FEAT**(core): DPoP (RFC 9449) crypto core — proof builder + key/thumbprint + manager. ([43f566d5](https://github.com/Bdaya-Dev/oidc/commit/43f566d509f6be194ba5f9190f7f8d19dc21ca4d)) + - **FEAT**(oidc_core): batch-3 audit hardening (signed_metadata verify, JWKS cache TTL, implicit/hybrid nonce assert). ([27a7f34d](https://github.com/Bdaya-Dev/oidc/commit/27a7f34d44723489c339b5169a3ad110919a9d6f)) + - **FEAT**(oidc_core): typed logout capability flags on provider metadata. ([74a6613a](https://github.com/Bdaya-Dev/oidc/commit/74a6613af511e1d5ff90e79957d61ed6a62d7ad4)) + - **FEAT**(core): Dynamic Client Registration + management (RFC 7591 / RFC 7592). ([55226655](https://github.com/Bdaya-Dev/oidc/commit/552266557dc0c5a87d3873f92e10240d52c64a4c)) + - **FEAT**(observability): native browser events via the existing OidcEvent stream (Phase 3). ([91d1f5bd](https://github.com/Bdaya-Dev/oidc/commit/91d1f5bdfa1526aec170474181ec71ad1bf38c59)) + - **BREAKING** **REFACTOR**: remove rxdart; adopt bdaya_shared_value ^5.0.0. ([0d65d7fd](https://github.com/Bdaya-Dev/oidc/commit/0d65d7fde062e2db7ffbdd31a47735c59954045a)) + - **BREAKING** **FIX**(core): always send PKCE (default S256), never downgrade (OAuth 2.1 / RFC 9700). ([99d1284a](https://github.com/Bdaya-Dev/oidc/commit/99d1284a274194fdc9cb030bbb240a5d4013f24f)) + - **BREAKING** **FIX**(core): fail-closed id_token verification + stricter validation (security). ([b4d92eaf](https://github.com/Bdaya-Dev/oidc/commit/b4d92eaf468a4405a8fef18602bcf132fcd254cc)) + - **BREAKING** **FEAT**(core): wire PAR (RFC 9126) into the authorization-code login flow. ([d2b44c32](https://github.com/Bdaya-Dev/oidc/commit/d2b44c323d2e0775595609ee619b42f2695d1e39)) + - **BREAKING** **FEAT**(core): id_token aud-strictness + at_hash validation (security). ([747d8a72](https://github.com/Bdaya-Dev/oidc/commit/747d8a72833a06feb5f8398fa26ce6d2cf74069e)) + - **BREAKING** **FEAT**(options): redesign native options API (v1 clean break, no AppAuth framing). ([a78954fe](https://github.com/Bdaya-Dev/oidc/commit/a78954feb4c4c6dfb0abc15f7e0a308be74d4e95)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + - **BREAKING** **FEAT**(core): revoke tokens on logout by default (RFC 7009). ([90a8cebd](https://github.com/Bdaya-Dev/oidc/commit/90a8cebdaa44359bb44f0f4103d39837780254f7)) + - **BREAKING** **FEAT**(oidc_android): add flowTimeoutSeconds to fix headless CI hang. ([01c844f5](https://github.com/Bdaya-Dev/oidc/commit/01c844f5bd98a3d983b9e50f9fa2192ed7013e50)) + - **BREAKING** **FEAT**(oidc_core): remove the strictJwtVerification fail-open opt-out. ([ee2146f9](https://github.com/Bdaya-Dev/oidc/commit/ee2146f9fa966c352a7c751673550bdcb5e7c0a5)) + - **BREAKING** **CHORE**: v1 dependency upgrade + drop the pigeon global-tool wrapper. ([45b62a3e](https://github.com/Bdaya-Dev/oidc/commit/45b62a3ef3f5b42cfb590111c9e37e144bbc11b0)) + +#### `oidc_darwin` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_darwin): implement flowTimeoutSeconds for the Apple ASWebAuthenticationSession flow. ([482f0186](https://github.com/Bdaya-Dev/oidc/commit/482f0186b8cdb37d309118de187e1c8496555d9a)) + - **DOCS**(oidc_darwin): add the on-device verification checklist. ([b22a1c06](https://github.com/Bdaya-Dev/oidc/commit/b22a1c06316a33198dbcd2826bee4fe4f9c608c8)) + - **BREAKING** **FEAT**: merge oidc_ios + oidc_macos into a unified oidc_darwin plugin. ([db73858e](https://github.com/Bdaya-Dev/oidc/commit/db73858e71b3b869326867b05b9d1ead3629acb9)) + +#### `oidc_desktop` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FEAT**(oidc): batch-2 audit hardening (loopback timeout, auth_time/max_age, resilient discovery parse, unverified-userinfo guard). ([d7f5965a](https://github.com/Bdaya-Dev/oidc/commit/d7f5965ac4ecbad0d7a79849e0e89d729a736169)) + - **BREAKING** **CHORE**: retire oidc_flutter_appauth (flutter_appauth fully removed). ([4805b1b4](https://github.com/Bdaya-Dev/oidc/commit/4805b1b4eff7c292a5cf826f2595f2ddd8b82944)) + +#### `oidc_platform_interface` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_platform_interface): declare meta as a direct dependency. ([21b79a43](https://github.com/Bdaya-Dev/oidc/commit/21b79a436649435c97221a4adf29321c9873d2bd)) + - **FIX**(native): harden iOS threading, simplify Android redirect to one-line setup. ([a7553f32](https://github.com/Bdaya-Dev/oidc/commit/a7553f326c1d67ac2bd057b0864688d73df24661)) + - **FEAT**(observability): native browser events via the existing OidcEvent stream (Phase 3). ([91d1f5bd](https://github.com/Bdaya-Dev/oidc/commit/91d1f5bdfa1526aec170474181ec71ad1bf38c59)) + - **BREAKING** **FEAT**: merge oidc_ios + oidc_macos into a unified oidc_darwin plugin. ([db73858e](https://github.com/Bdaya-Dev/oidc/commit/db73858e71b3b869326867b05b9d1ead3629acb9)) + - **BREAKING** **FEAT**(native): migrate native transport to Pigeon + automate codegen. ([fc7606f3](https://github.com/Bdaya-Dev/oidc/commit/fc7606f3329cc493281a438ff76482436b018709)) + - **BREAKING** **FEAT**(oidc_macos): first-party ASWebAuthenticationSession; drop flutter_appauth. ([dc13f411](https://github.com/Bdaya-Dev/oidc/commit/dc13f411a3bfca4572a0f0e8fea2705365314d3c)) + - **BREAKING** **CHORE**: v1 dependency upgrade + drop the pigeon global-tool wrapper. ([45b62a3e](https://github.com/Bdaya-Dev/oidc/commit/45b62a3ef3f5b42cfb590111c9e37e144bbc11b0)) + +#### `oidc_web_core` - `v1.0.0` + + - **FIX**(oidc_web_core): stop the samePage end-session test from navigating the suite away ([#382](https://github.com/Bdaya-Dev/oidc/issues/382)). ([d1cd114e](https://github.com/Bdaya-Dev/oidc/commit/d1cd114e4ec505a5ace75fd28512ff16ff1f474e)) + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_web_core): secureTokens silently persisted as plaintext on Firefox ([#360](https://github.com/Bdaya-Dev/oidc/issues/360)). ([656025d8](https://github.com/Bdaya-Dev/oidc/commit/656025d8175c1c4e27c827622e0433be336dd245)) + - **FIX**(oidc_core): close 7 P0 spec-audit findings ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([60907e96](https://github.com/Bdaya-Dev/oidc/commit/60907e96f33dce8bf961b26ed43cec20f56e595e)) + - **FIX**(oidc_web_core): avoid COOP closed-window false positives. ([5b24209b](https://github.com/Bdaya-Dev/oidc/commit/5b24209bafc99d21f3d6f85e320e0a110feebe43)) + - **FIX**(oidc_web_core): detect closed auth window ([#303](https://github.com/Bdaya-Dev/oidc/issues/303)). ([36f7a340](https://github.com/Bdaya-Dev/oidc/commit/36f7a34010fe1e8d62834592cd1084f4c4e5e0e9)) + - **FEAT**(oidc_web_core): encrypt secureTokens at rest (AES-GCM via WebCrypto) ([#324](https://github.com/Bdaya-Dev/oidc/issues/324) item 15). ([cf504300](https://github.com/Bdaya-Dev/oidc/commit/cf504300b1922a7f68a82c39ab8dfb42aafe487d)) + - **BREAKING** **REFACTOR**: remove rxdart; adopt bdaya_shared_value ^5.0.0. ([0d65d7fd](https://github.com/Bdaya-Dev/oidc/commit/0d65d7fde062e2db7ffbdd31a47735c59954045a)) + +#### `oidc_default_store` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_core): close 7 P0 spec-audit findings ([#324](https://github.com/Bdaya-Dev/oidc/issues/324)). ([60907e96](https://github.com/Bdaya-Dev/oidc/commit/60907e96f33dce8bf961b26ed43cec20f56e595e)) + - **FEAT**(storage): harden token storage at rest (RFC 9700 §4.9.3). ([76111b4a](https://github.com/Bdaya-Dev/oidc/commit/76111b4a1022a140c0b510e088becaed54835c0f)) + +#### `oidc_linux` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(spm,native): SwiftPM layout migration + conformance fixes; fix Android build. ([47f7bd25](https://github.com/Bdaya-Dev/oidc/commit/47f7bd25feb5d4b7a2e3db67165a44ecdf4dae29)) + +#### `oidc_loopback_listener` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FEAT**(oidc): batch-2 audit hardening (loopback timeout, auth_time/max_age, resilient discovery parse, unverified-userinfo guard). ([d7f5965a](https://github.com/Bdaya-Dev/oidc/commit/d7f5965ac4ecbad0d7a79849e0e89d729a736169)) + +#### `oidc_web` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FEAT**(oidc_web_core): encrypt secureTokens at rest (AES-GCM via WebCrypto) ([#324](https://github.com/Bdaya-Dev/oidc/issues/324) item 15). ([cf504300](https://github.com/Bdaya-Dev/oidc/commit/cf504300b1922a7f68a82c39ab8dfb42aafe487d)) + +#### `oidc_windows` - `v1.0.0` + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(spm,native): SwiftPM layout migration + conformance fixes; fix Android build. ([47f7bd25](https://github.com/Bdaya-Dev/oidc/commit/47f7bd25feb5d4b7a2e3db67165a44ecdf4dae29)) + + +## 2026-02-10 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc` - `v0.14.0+2`](#oidc---v01402) + - [`oidc_android` - `v0.8.0+3`](#oidc_android---v0803) + - [`oidc_cli` - `v0.0.2+2`](#oidc_cli---v0022) + - [`oidc_core` - `v0.16.1+1`](#oidc_core---v01611) + - [`oidc_default_store` - `v0.6.0+2`](#oidc_default_store---v0602) + - [`oidc_desktop` - `v0.7.0+3`](#oidc_desktop---v0703) + - [`oidc_flutter_appauth` - `v0.7.0+3`](#oidc_flutter_appauth---v0703) + - [`oidc_ios` - `v0.8.0+3`](#oidc_ios---v0803) + - [`oidc_linux` - `v0.5.0+3`](#oidc_linux---v0503) + - [`oidc_loopback_listener` - `v0.3.0+1`](#oidc_loopback_listener---v0301) + - [`oidc_macos` - `v0.8.0+3`](#oidc_macos---v0803) + - [`oidc_platform_interface` - `v0.7.0+3`](#oidc_platform_interface---v0703) + - [`oidc_web` - `v0.7.0+3`](#oidc_web---v0703) + - [`oidc_web_core` - `v0.4.0+3`](#oidc_web_core---v0403) + - [`oidc_windows` - `v0.4.0+3`](#oidc_windows---v0403) + +--- + +#### `oidc` - `v0.14.0+2` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_android` - `v0.8.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_cli` - `v0.0.2+2` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_core` - `v0.16.1+1` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_default_store` - `v0.6.0+2` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_desktop` - `v0.7.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_flutter_appauth` - `v0.7.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_ios` - `v0.8.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_linux` - `v0.5.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_loopback_listener` - `v0.3.0+1` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_macos` - `v0.8.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_platform_interface` - `v0.7.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_web` - `v0.7.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_web_core` - `v0.4.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +#### `oidc_windows` - `v0.4.0+3` + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + + +## 2026-02-10 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc_core` - `v0.16.1`](#oidc_core---v0161) + - [`oidc` - `v0.14.0+1`](#oidc---v01401) + - [`oidc_android` - `v0.8.0+2`](#oidc_android---v0802) + - [`oidc_cli` - `v0.0.2+1`](#oidc_cli---v0021) + - [`oidc_default_store` - `v0.6.0+1`](#oidc_default_store---v0601) + - [`oidc_desktop` - `v0.7.0+2`](#oidc_desktop---v0702) + - [`oidc_flutter_appauth` - `v0.7.0+2`](#oidc_flutter_appauth---v0702) + - [`oidc_ios` - `v0.8.0+2`](#oidc_ios---v0802) + - [`oidc_linux` - `v0.5.0+2`](#oidc_linux---v0502) + - [`oidc_macos` - `v0.8.0+2`](#oidc_macos---v0802) + - [`oidc_platform_interface` - `v0.7.0+2`](#oidc_platform_interface---v0702) + - [`oidc_web` - `v0.7.0+2`](#oidc_web---v0702) + - [`oidc_web_core` - `v0.4.0+2`](#oidc_web_core---v0402) + - [`oidc_windows` - `v0.4.0+2`](#oidc_windows---v0402) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc` - `v0.14.0+1` + - `oidc_android` - `v0.8.0+2` + - `oidc_cli` - `v0.0.2+1` + - `oidc_default_store` - `v0.6.0+1` + - `oidc_desktop` - `v0.7.0+2` + - `oidc_flutter_appauth` - `v0.7.0+2` + - `oidc_ios` - `v0.8.0+2` + - `oidc_linux` - `v0.5.0+2` + - `oidc_macos` - `v0.8.0+2` + - `oidc_platform_interface` - `v0.7.0+2` + - `oidc_web` - `v0.7.0+2` + - `oidc_web_core` - `v0.4.0+2` + - `oidc_windows` - `v0.4.0+2` + +--- + +#### `oidc_core` - `v0.16.1` + + - **FEAT**(oidc_core): add extraTokenHeaders parameter to loginPassword. ([e00c753b](https://github.com/Bdaya-Dev/oidc/commit/e00c753be7f774b23b8a585c590df8936deaaf97)) + + +## 2025-12-31 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v0.14.0`](#oidc---v0140) + - [`oidc_core` - `v0.16.0`](#oidc_core---v0160) + - [`oidc_default_store` - `v0.6.0`](#oidc_default_store---v060) + +Packages with other changes: + + - [`oidc_cli` - `v0.0.2`](#oidc_cli---v002) + - [`oidc_web_core` - `v0.4.0+1`](#oidc_web_core---v0401) + - [`oidc_android` - `v0.8.0+1`](#oidc_android---v0801) + - [`oidc_desktop` - `v0.7.0+1`](#oidc_desktop---v0701) + - [`oidc_flutter_appauth` - `v0.7.0+1`](#oidc_flutter_appauth---v0701) + - [`oidc_ios` - `v0.8.0+1`](#oidc_ios---v0801) + - [`oidc_linux` - `v0.5.0+1`](#oidc_linux---v0501) + - [`oidc_macos` - `v0.8.0+1`](#oidc_macos---v0801) + - [`oidc_platform_interface` - `v0.7.0+1`](#oidc_platform_interface---v0701) + - [`oidc_web` - `v0.7.0+1`](#oidc_web---v0701) + - [`oidc_windows` - `v0.4.0+1`](#oidc_windows---v0401) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_android` - `v0.8.0+1` + - `oidc_desktop` - `v0.7.0+1` + - `oidc_flutter_appauth` - `v0.7.0+1` + - `oidc_ios` - `v0.8.0+1` + - `oidc_linux` - `v0.5.0+1` + - `oidc_macos` - `v0.8.0+1` + - `oidc_platform_interface` - `v0.7.0+1` + - `oidc_web` - `v0.7.0+1` + - `oidc_windows` - `v0.4.0+1` + +--- + +#### `oidc` - `v0.14.0` + + - **FEAT**: improve offline mode integration tests with app startup handling. ([18a586c9](https://github.com/Bdaya-Dev/oidc/commit/18a586c933ddfdbf416510e95c63281d3401bedc)) + - **FEAT**: update dependencies and enhance offline mode test handling. ([a57d6490](https://github.com/Bdaya-Dev/oidc/commit/a57d64906bef50ac362bea16045ea6ba98f7b3f0)) + - **FEAT**: enhance offline mode handling in tests and user manager. ([e0cae79a](https://github.com/Bdaya-Dev/oidc/commit/e0cae79adf3fa8ccf18a530dbdda7cc339a75c0d)) + - **BREAKING** **FEAT**(oidc_default_store): use flutter_secure_storage. ([a9441511](https://github.com/Bdaya-Dev/oidc/commit/a9441511a9f27aa742cde2459b0654541b0aa356)) + - **BREAKING** **FEAT**: Add offline mode events and error handling. ([7479fd15](https://github.com/Bdaya-Dev/oidc/commit/7479fd15cf0029e9b865333709a98363b5119d64)) + +#### `oidc_core` - `v0.16.0` + + - **REFACTOR**: remove unnecessary @protected annotations from offline mode tracking variables. ([56da9bab](https://github.com/Bdaya-Dev/oidc/commit/56da9bab4d555c8134d68c7e0151e7fe6d3753be)) + - **FEAT**: update copy_with_extension and copy_with_extension_gen dependencies to support version range. ([3569919e](https://github.com/Bdaya-Dev/oidc/commit/3569919ecd6e7575b792d5c3a4e7c503265fc456)) + - **FEAT**: update dependencies and adjust test configurations for improved compatibility. ([18fef073](https://github.com/Bdaya-Dev/oidc/commit/18fef073f4a66e3e9dda7f73e756992ecd47c587)) + - **FEAT**: update dependencies and enhance offline mode test handling. ([a57d6490](https://github.com/Bdaya-Dev/oidc/commit/a57d64906bef50ac362bea16045ea6ba98f7b3f0)) + - **FEAT**: enhance offline mode handling in tests and user manager. ([e0cae79a](https://github.com/Bdaya-Dev/oidc/commit/e0cae79adf3fa8ccf18a530dbdda7cc339a75c0d)) + - **BREAKING** **FEAT**: Add offline mode events and error handling. ([7479fd15](https://github.com/Bdaya-Dev/oidc/commit/7479fd15cf0029e9b865333709a98363b5119d64)) + +#### `oidc_default_store` - `v0.6.0` + + - **BREAKING** **FEAT**(oidc_default_store): use flutter_secure_storage. ([a9441511](https://github.com/Bdaya-Dev/oidc/commit/a9441511a9f27aa742cde2459b0654541b0aa356)) + - **BREAKING** **FEAT**: Add offline mode events and error handling. ([7479fd15](https://github.com/Bdaya-Dev/oidc/commit/7479fd15cf0029e9b865333709a98363b5119d64)) + +#### `oidc_cli` - `v0.0.2` + + - **FIX**(oidc_cli): relax build_runner to avoid Flutter SDK pin conflicts. ([b8697530](https://github.com/Bdaya-Dev/oidc/commit/b86975307bde79bd00ba4b0ddacc558a875ed2f8)) + - **FEAT**(oidc_cli): add provider-agnostic CLI package. ([2598ba4f](https://github.com/Bdaya-Dev/oidc/commit/2598ba4ffa0882fed971b7ecd44c1516bf7bef55)) + +#### `oidc_web_core` - `v0.4.0+1` + + - **FIX**(oidc_web_core): use isA() for JS interop checks. ([724c9a2a](https://github.com/Bdaya-Dev/oidc/commit/724c9a2a0ce9792653c58d17ec5f4122750278f3)) + + +## 2025-10-10 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v0.13.0`](#oidc---v0130) + - [`oidc_android` - `v0.8.0`](#oidc_android---v080) + - [`oidc_core` - `v0.15.0`](#oidc_core---v0150) + - [`oidc_default_store` - `v0.5.0`](#oidc_default_store---v050) + - [`oidc_desktop` - `v0.7.0`](#oidc_desktop---v070) + - [`oidc_flutter_appauth` - `v0.7.0`](#oidc_flutter_appauth---v070) + - [`oidc_ios` - `v0.8.0`](#oidc_ios---v080) + - [`oidc_linux` - `v0.5.0`](#oidc_linux---v050) + - [`oidc_loopback_listener` - `v0.3.0`](#oidc_loopback_listener---v030) + - [`oidc_macos` - `v0.8.0`](#oidc_macos---v080) + - [`oidc_platform_interface` - `v0.7.0`](#oidc_platform_interface---v070) + - [`oidc_web` - `v0.7.0`](#oidc_web---v070) + - [`oidc_web_core` - `v0.4.0`](#oidc_web_core---v040) + - [`oidc_windows` - `v0.4.0`](#oidc_windows---v040) + +Packages with other changes: + + - There are no other changes in this release. + +--- + +#### `oidc` - `v0.13.0` + + - **FEAT**: add OIDC conformance test suite with API client and test runner. ([a651a7d8](https://github.com/Bdaya-Dev/oidc/commit/a651a7d814a424683d141fcc66156e5d17112baa)) + - **BREAKING** **FIX**: hasInit is set to true too early in init() of OidcUserManagerBase ([#275](https://github.com/Bdaya-Dev/oidc/issues/275)). ([d704aa5f](https://github.com/Bdaya-Dev/oidc/commit/d704aa5fe7449051831fc062919712a1c5075a13)) + - **BREAKING** **FIX**: migrate to simple_secure_storage ([#270](https://github.com/Bdaya-Dev/oidc/issues/270)). ([723560a7](https://github.com/Bdaya-Dev/oidc/commit/723560a7e7d212290205724d7af6799f217ab778)) + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_android` - `v0.8.0` + + - **BREAKING** **FIX**: hasInit is set to true too early in init() of OidcUserManagerBase ([#275](https://github.com/Bdaya-Dev/oidc/issues/275)). ([d704aa5f](https://github.com/Bdaya-Dev/oidc/commit/d704aa5fe7449051831fc062919712a1c5075a13)) + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_core` - `v0.15.0` + + - **FIX**: refactor oidcExecuteHook to handle request and response modifications. ([806c6411](https://github.com/Bdaya-Dev/oidc/commit/806c641166c0634716fdd58f43f10d187f3defe4)) + - **BREAKING** **FIX**: hasInit is set to true too early in init() of OidcUserManagerBase ([#275](https://github.com/Bdaya-Dev/oidc/issues/275)). ([d704aa5f](https://github.com/Bdaya-Dev/oidc/commit/d704aa5fe7449051831fc062919712a1c5075a13)) + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_default_store` - `v0.5.0` + + - **BREAKING** **FIX**: hasInit is set to true too early in init() of OidcUserManagerBase ([#275](https://github.com/Bdaya-Dev/oidc/issues/275)). ([d704aa5f](https://github.com/Bdaya-Dev/oidc/commit/d704aa5fe7449051831fc062919712a1c5075a13)) + - **BREAKING** **FIX**: migrate to simple_secure_storage ([#270](https://github.com/Bdaya-Dev/oidc/issues/270)). ([723560a7](https://github.com/Bdaya-Dev/oidc/commit/723560a7e7d212290205724d7af6799f217ab778)) + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_desktop` - `v0.7.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_flutter_appauth` - `v0.7.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_ios` - `v0.8.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_linux` - `v0.5.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_loopback_listener` - `v0.3.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_macos` - `v0.8.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_platform_interface` - `v0.7.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_web` - `v0.7.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_web_core` - `v0.4.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +#### `oidc_windows` - `v0.4.0` + + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + + +## 2025-06-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc` - `v0.12.1+2`](#oidc---v01212) + - [`oidc_android` - `v0.7.0+5`](#oidc_android---v0705) + - [`oidc_core` - `v0.14.2+1`](#oidc_core---v01421) + - [`oidc_default_store` - `v0.4.0+3`](#oidc_default_store---v0403) + - [`oidc_desktop` - `v0.6.1+2`](#oidc_desktop---v0612) + - [`oidc_flutter_appauth` - `v0.6.0+5`](#oidc_flutter_appauth---v0605) + - [`oidc_ios` - `v0.7.0+5`](#oidc_ios---v0705) + - [`oidc_linux` - `v0.4.1+2`](#oidc_linux---v0412) + - [`oidc_loopback_listener` - `v0.2.0+1`](#oidc_loopback_listener---v0201) + - [`oidc_macos` - `v0.7.0+5`](#oidc_macos---v0705) + - [`oidc_platform_interface` - `v0.6.0+9`](#oidc_platform_interface---v0609) + - [`oidc_web` - `v0.6.0+9`](#oidc_web---v0609) + - [`oidc_web_core` - `v0.3.1+3`](#oidc_web_core---v0313) + - [`oidc_windows` - `v0.3.1+14`](#oidc_windows---v03114) + +--- + +#### `oidc` - `v0.12.1+2` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_android` - `v0.7.0+5` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_core` - `v0.14.2+1` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_default_store` - `v0.4.0+3` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_desktop` - `v0.6.1+2` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_flutter_appauth` - `v0.6.0+5` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_ios` - `v0.7.0+5` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_linux` - `v0.4.1+2` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_loopback_listener` - `v0.2.0+1` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_macos` - `v0.7.0+5` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_platform_interface` - `v0.6.0+9` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_web` - `v0.6.0+9` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_web_core` - `v0.3.1+3` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +#### `oidc_windows` - `v0.3.1+14` + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + + +## 2025-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc_core` - `v0.14.2`](#oidc_core---v0142) + - [`oidc_platform_interface` - `v0.6.0+8`](#oidc_platform_interface---v0608) + - [`oidc_desktop` - `v0.6.1+1`](#oidc_desktop---v0611) + - [`oidc_ios` - `v0.7.0+4`](#oidc_ios---v0704) + - [`oidc_flutter_appauth` - `v0.6.0+4`](#oidc_flutter_appauth---v0604) + - [`oidc_macos` - `v0.7.0+4`](#oidc_macos---v0704) + - [`oidc_web` - `v0.6.0+8`](#oidc_web---v0608) + - [`oidc_android` - `v0.7.0+4`](#oidc_android---v0704) + - [`oidc_default_store` - `v0.4.0+2`](#oidc_default_store---v0402) + - [`oidc_linux` - `v0.4.1+1`](#oidc_linux---v0411) + - [`oidc` - `v0.12.1+1`](#oidc---v01211) + - [`oidc_windows` - `v0.3.1+13`](#oidc_windows---v03113) + - [`oidc_web_core` - `v0.3.1+2`](#oidc_web_core---v0312) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_platform_interface` - `v0.6.0+8` + - `oidc_desktop` - `v0.6.1+1` + - `oidc_ios` - `v0.7.0+4` + - `oidc_flutter_appauth` - `v0.6.0+4` + - `oidc_macos` - `v0.7.0+4` + - `oidc_web` - `v0.6.0+8` + - `oidc_android` - `v0.7.0+4` + - `oidc_default_store` - `v0.4.0+2` + - `oidc_linux` - `v0.4.1+1` + - `oidc` - `v0.12.1+1` + - `oidc_windows` - `v0.3.1+13` + - `oidc_web_core` - `v0.3.1+2` + +--- + +#### `oidc_core` - `v0.14.2` + + - **REFACTOR**: remove unnecessary library declaration and test annotation from device_authorization_test.dart. ([a8886cfe](https://github.com/Bdaya-Dev/oidc/commit/a8886cfe5c431612be2e3b7dd1db98db53dd6356)) + - **FEAT**: add token revocation methods to OidcUserManager. ([4850788f](https://github.com/Bdaya-Dev/oidc/commit/4850788f72f759479c67b16776ff4c43bc3f6b47)) + - **FEAT**: add token revocation support with request and response models. ([a6b635f0](https://github.com/Bdaya-Dev/oidc/commit/a6b635f03659f486bebfe3c53d7df0a26e3a5b4c)) + + +## 2025-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`oidc` - `v0.12.1`](#oidc---v0121) + - [`oidc_core` - `v0.14.1`](#oidc_core---v0141) + - [`oidc_desktop` - `v0.6.1`](#oidc_desktop---v061) + - [`oidc_linux` - `v0.4.1`](#oidc_linux---v041) + - [`oidc_android` - `v0.7.0+3`](#oidc_android---v0703) + - [`oidc_default_store` - `v0.4.0+1`](#oidc_default_store---v0401) + - [`oidc_ios` - `v0.7.0+3`](#oidc_ios---v0703) + - [`oidc_flutter_appauth` - `v0.6.0+3`](#oidc_flutter_appauth---v0603) + - [`oidc_macos` - `v0.7.0+3`](#oidc_macos---v0703) + - [`oidc_platform_interface` - `v0.6.0+7`](#oidc_platform_interface---v0607) + - [`oidc_web_core` - `v0.3.1+1`](#oidc_web_core---v0311) + - [`oidc_web` - `v0.6.0+7`](#oidc_web---v0607) + - [`oidc_windows` - `v0.3.1+12`](#oidc_windows---v03112) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_android` - `v0.7.0+3` + - `oidc_default_store` - `v0.4.0+1` + - `oidc_ios` - `v0.7.0+3` + - `oidc_flutter_appauth` - `v0.6.0+3` + - `oidc_macos` - `v0.7.0+3` + - `oidc_platform_interface` - `v0.6.0+7` + - `oidc_web_core` - `v0.3.1+1` + - `oidc_web` - `v0.6.0+7` + - `oidc_windows` - `v0.3.1+12` + +--- + +#### `oidc` - `v0.12.1` + + - **FEAT**: update changelogs to reflect breaking changes and new features for multiple OIDC platforms. ([4caca121](https://github.com/Bdaya-Dev/oidc/commit/4caca121f63fd21d71aaffa2730b092fc26a7da5)) + +#### `oidc_core` - `v0.14.1` + + - **FEAT**: update changelogs to reflect breaking changes and new features for multiple OIDC platforms. ([4caca121](https://github.com/Bdaya-Dev/oidc/commit/4caca121f63fd21d71aaffa2730b092fc26a7da5)) + +#### `oidc_desktop` - `v0.6.1` + + - **FEAT**: update changelogs to reflect breaking changes and new features for multiple OIDC platforms. ([4caca121](https://github.com/Bdaya-Dev/oidc/commit/4caca121f63fd21d71aaffa2730b092fc26a7da5)) + +#### `oidc_linux` - `v0.4.1` + + - **FEAT**: update changelogs to reflect breaking changes and new features for multiple OIDC platforms. ([4caca121](https://github.com/Bdaya-Dev/oidc/commit/4caca121f63fd21d71aaffa2730b092fc26a7da5)) + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc_linux` - `v0.4.0`](#oidc_linux---v040) + +Packages with other changes: + + - [`oidc` - `v0.12.0`](#oidc---v0120) + - [`oidc_core` - `v0.14.0`](#oidc_core---v0140) + - [`oidc_default_store` - `v0.4.0`](#oidc_default_store---v040) + - [`oidc_desktop` - `v0.6.0`](#oidc_desktop---v060) + - [`oidc_web_core` - `v0.3.1`](#oidc_web_core---v031) + - [`oidc_android` - `v0.7.0+2`](#oidc_android---v0702) + - [`oidc_flutter_appauth` - `v0.6.0+2`](#oidc_flutter_appauth---v0602) + - [`oidc_ios` - `v0.7.0+2`](#oidc_ios---v0702) + - [`oidc_macos` - `v0.7.0+2`](#oidc_macos---v0702) + - [`oidc_platform_interface` - `v0.6.0+6`](#oidc_platform_interface---v0606) + - [`oidc_web` - `v0.6.0+6`](#oidc_web---v0606) + - [`oidc_windows` - `v0.3.1+11`](#oidc_windows---v03111) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_android` - `v0.7.0+2` + - `oidc_flutter_appauth` - `v0.6.0+2` + - `oidc_ios` - `v0.7.0+2` + - `oidc_macos` - `v0.7.0+2` + - `oidc_platform_interface` - `v0.6.0+6` + - `oidc_web` - `v0.6.0+6` + - `oidc_windows` - `v0.3.1+11` + +--- + +#### `oidc_linux` - `v0.4.0` + + - **DOCS**: Document multi-manager support. ([0b6090ad](https://github.com/Bdaya-Dev/oidc/commit/0b6090ad266838bd39d139201b68d44351a5ab7c)) + +#### `oidc` - `v0.12.0` + + - **FEAT**: Enhance OIDC store with manager ID support. ([56f42f2d](https://github.com/Bdaya-Dev/oidc/commit/56f42f2d67fd97c587611870e412de8cb357c4e4)) + +#### `oidc_core` - `v0.14.0` + + - **FEAT**: implement custom URL launcher for OidcLinux platform. ([11d901fe](https://github.com/Bdaya-Dev/oidc/commit/11d901fede70dd8aaa9cb03df18c392142895ccb)) + - **FEAT**: enhance OIDC conformance token handling in integration tests and user manager. ([1947c29f](https://github.com/Bdaya-Dev/oidc/commit/1947c29fbd9ab20d0bd62065f697dac2fba1f682)) + - **FEAT**: Enhance OIDC store with manager ID support. ([56f42f2d](https://github.com/Bdaya-Dev/oidc/commit/56f42f2d67fd97c587611870e412de8cb357c4e4)) + +#### `oidc_default_store` - `v0.4.0` + + - **FEAT**: Enhance OIDC store with manager ID support. ([56f42f2d](https://github.com/Bdaya-Dev/oidc/commit/56f42f2d67fd97c587611870e412de8cb357c4e4)) + +#### `oidc_desktop` - `v0.6.0` + + - **FIX**: enable headless mode for Chrome in default Linux applications setup. ([dda1c2dc](https://github.com/Bdaya-Dev/oidc/commit/dda1c2dc9f43a034d553eb043294b80e000f82f3)) + - **FEAT**: implement custom URL launcher for OidcLinux platform. ([11d901fe](https://github.com/Bdaya-Dev/oidc/commit/11d901fede70dd8aaa9cb03df18c392142895ccb)) + - **FEAT**: improve auth URL launching with enhanced error handling and logging. ([58f3881a](https://github.com/Bdaya-Dev/oidc/commit/58f3881a3e629896acf933862bb1e6a131bb6b4e)) + +#### `oidc_web_core` - `v0.3.1` + + - **FEAT**: Enhance OIDC store with manager ID support. ([56f42f2d](https://github.com/Bdaya-Dev/oidc/commit/56f42f2d67fd97c587611870e412de8cb357c4e4)) + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-06-06 + +### Changes + +--- + +Packages with breaking changes: + + - [`oidc` - `v0.11.0`](#oidc---v0110) + - [`oidc_core` - `v0.13.0`](#oidc_core---v0130) + - [`oidc_default_store` - `v0.3.0`](#oidc_default_store---v030) + +Packages with other changes: + + - [`oidc_web_core` - `v0.3.0+5`](#oidc_web_core---v0305) + - [`oidc_desktop` - `v0.5.0+5`](#oidc_desktop---v0505) + - [`oidc_ios` - `v0.7.0+1`](#oidc_ios---v0701) + - [`oidc_platform_interface` - `v0.6.0+5`](#oidc_platform_interface---v0605) + - [`oidc_macos` - `v0.7.0+1`](#oidc_macos---v0701) + - [`oidc_android` - `v0.7.0+1`](#oidc_android---v0701) + - [`oidc_web` - `v0.6.0+5`](#oidc_web---v0605) + - [`oidc_linux` - `v0.3.0+16`](#oidc_linux---v03016) + - [`oidc_flutter_appauth` - `v0.6.0+1`](#oidc_flutter_appauth---v0601) + - [`oidc_windows` - `v0.3.1+10`](#oidc_windows---v03110) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `oidc_web_core` - `v0.3.0+5` + - `oidc_desktop` - `v0.5.0+5` + - `oidc_ios` - `v0.7.0+1` + - `oidc_platform_interface` - `v0.6.0+5` + - `oidc_macos` - `v0.7.0+1` + - `oidc_android` - `v0.7.0+1` + - `oidc_web` - `v0.6.0+5` + - `oidc_linux` - `v0.3.0+16` + - `oidc_flutter_appauth` - `v0.6.0+1` + - `oidc_windows` - `v0.3.1+10` + +--- + +#### `oidc` - `v0.11.0` + + - **BREAKING** **FEAT**: support hooks ([#228](https://github.com/Bdaya-Dev/oidc/issues/228)). ([f2d9d9c6](https://github.com/Bdaya-Dev/oidc/commit/f2d9d9c692e0cf0baac36f186be337ff62e142df)) + +#### `oidc_core` - `v0.13.0` + + - **BREAKING** **FEAT**: support hooks ([#228](https://github.com/Bdaya-Dev/oidc/issues/228)). ([f2d9d9c6](https://github.com/Bdaya-Dev/oidc/commit/f2d9d9c692e0cf0baac36f186be337ff62e142df)) + +#### `oidc_default_store` - `v0.3.0` + + - **FIX**: oidc_default_store init() loads the shared preferences again and does not check if already given in ctor [#226](https://github.com/Bdaya-Dev/oidc/issues/226). ([111b0a98](https://github.com/Bdaya-Dev/oidc/commit/111b0a98e2eb57b32ab3c9ace3d9b543a2683f34)) + - **BREAKING** **FEAT**: support hooks ([#228](https://github.com/Bdaya-Dev/oidc/issues/228)). ([f2d9d9c6](https://github.com/Bdaya-Dev/oidc/commit/f2d9d9c692e0cf0baac36f186be337ff62e142df)) + + ## 2025-04-16 ### Changes diff --git a/README.md b/README.md index ab1929c7..7abf7533 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # oidc +[![openid certification](http://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg)](https://openid.net/developers/certified-openid-connect-implementations/) [![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) [![codecov](https://codecov.io/github/Bdaya-Dev/oidc/graph/badge.svg?token=HSEDM6I7TH)](https://codecov.io/github/Bdaya-Dev/oidc) @@ -14,6 +15,7 @@ This is a Mono repo of multiple packages: The main dart package, it implements the basic openid/oauth specification, except the platform specific parts (like redirection) - [oidc](packages/oidc/README.md): builds on top of `oidc_core`, and adds platform-specific parts, this also contains the `UserManager` class. - [oidc_default_store](packages/oidc_default_store/README.md): the default `OidcStore` store implementation. + - [oidc_cli](packages/oidc_cli/README.md): a provider-agnostic CLI for authenticating against an OIDC provider. Make sure you read the [![Static Badge](https://img.shields.io/badge/wiki-purple)](https://bdaya-dev.github.io/oidc/) for extra information. diff --git a/codecov.yml b/codecov.yml index b9a988d0..39215f54 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,12 @@ coverage: status: patch: false - project: false \ No newline at end of file + project: false + +# Reporting-side noise exclusion. Generated code and the example app are not +# library surface; version.dart is generated (header: "Generated code. Do not +# modify.") but does not match the *.g.dart convention. +ignore: + - "**/*.g.dart" + - "**/example/**" + - "packages/oidc_cli/lib/src/version.dart" diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/docs/index.md b/docs/index.md index a54afef0..5a68a9a5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,5 @@ +[![openid certification](https://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg)](https://openid.net/developers/certified-openid-connect-implementations/) + This is a federated plugin split into the following main packages: - [oidc](oidc-getting-started.md): Ready to use flutter plugin that conforms to the OIDC spec. ([pub.dev](https://pub.dev/packages/oidc)) @@ -5,15 +7,14 @@ This is a federated plugin split into the following main packages: - [oidc_web_core](oidc_web_core.md): Pure dart package based on [package:web](https://pub.dev/packages/web) which allows. ([pub.dev](https://pub.dev/packages/oidc_web_core)) - [oidc_default_store](oidc_default_store.md): An implementation of `OidcStore` that uses [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) and [shared_preferences](https://pub.dev/packages/shared_preferences). ([pub.dev](https://pub.dev/packages/oidc_default_store)) - [oidc_loopback_listener](oidc_loopback_listener.md): A pure dart package that creates a simple http server. ([pub.dev](https://pub.dev/packages/oidc_loopback_listener)) +- [oidc_cli](oidc_cli.md): A provider-agnostic CLI for authenticating against an OIDC provider (useful for local dev and scripting). And the following internal packages: - [oidc_platform_interface](https://pub.dev/packages/oidc_platform_interface): common interface that needs to be implemented in a platform. -- [oidc_android](https://pub.dev/packages/oidc_android): Android implementation. -- [oidc_ios](https://pub.dev/packages/oidc_ios): ios implementation. -- [oidc_macos](https://pub.dev/packages/oidc_macos): macos implementation. -- [oidc_flutter_appauth](https://pub.dev/packages/oidc_flutter_appauth): Base Implementation connecting packages with [flutter_appauth](https://pub.dev/packages/flutter_appauth), to use the [AppAuth SDK](https://appauth.io/), used in android, ios, macos. +- [oidc_android](https://pub.dev/packages/oidc_android): Android implementation — first-party **Chrome Custom Tabs** (no third-party SDK). +- [oidc_darwin](https://pub.dev/packages/oidc_darwin): iOS + macOS implementation — first-party **ASWebAuthenticationSession** (no third-party SDK; iOS 13+ / macOS 10.15+). Merges and supersedes the former `oidc_ios` and `oidc_macos`. - [oidc_web](https://pub.dev/packages/oidc_web): Web implementation. diff --git a/docs/oidc-getting-started.md b/docs/oidc-getting-started.md index 0ded9155..e0a34a9b 100644 --- a/docs/oidc-getting-started.md +++ b/docs/oidc-getting-started.md @@ -17,53 +17,93 @@ dart pub add oidc oidc_default_store oidc_core every platform you support has its own configuration and set up. -for Android, IOS, Macos, The current implementation relies on the [appauth SDK](https://appauth.io/) made by openid themselves, we use it via [![flutter_appauth][flutter_appauth_image]][flutter_appauth_link]. +Native browser handling is **first-party on every platform** (no third-party +auth SDK): + +- **Android** — **Auth Tab**, falling back to **Chrome Custom Tabs** (requires + **minSdk 23+**). +- **iOS** — **ASWebAuthenticationSession** (requires **iOS 13+**). +- **macOS** — **ASWebAuthenticationSession** (requires **macOS 10.15+**). we also rely on [![flutter_secure_storage][flutter_secure_storage_image]][flutter_secure_storage_link] in our `oidc_default_store` implementation, to encrypt the stored access tokens. ## Android -### AppAuth Android setup -[Source](https://github.com/openid/AppAuth-Android#capturing-the-authorization-redirect) +### Redirect handling (Auth Tab / Chrome Custom Tabs) + +`oidc_android` opens the system browser via Auth Tab (falling back to Chrome +Custom Tabs) and ships its own transparent redirect receiver +(`OidcRedirectActivity`), so you only declare the **scheme** of your +`redirect_uri` with a single manifest placeholder. -go to `android/app/build.gradle`, and add the following line under `defaultConfig`: +Auth Tab returns the redirect through the Activity Result API, which needs both +Chrome 137+ **and** a host activity that extends `ComponentActivity` — that is +`FlutterFragmentActivity`, not the default `FlutterActivity`. Anything else — +another browser, or the default host — degrades to a plain Custom Tab, and the +redirect is caught by the receiver's intent-filter instead. The placeholder +below is what wires that receiver up, so it is required for login to work. + +go to `android/app/build.gradle`, and add the following under `defaultConfig`: ```diff -defaultConfig { - - applicationId "com.my_app" - minSdkVersion flutter.minSdkVersion +defaultConfig { + applicationId "com.my.app" +- minSdkVersion flutter.minSdkVersion ++ minSdkVersion 23 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + manifestPlaceholders += [ -+ 'appAuthRedirectScheme': 'com.my.app' ++ 'oidcRedirectScheme': 'com.my.app' + ] } ``` -replace `com.my.app` with your `applicationId` + +`minSdkVersion` must be **23 or higher**. `oidc_android` depends on +`androidx.browser:browser:1.10.0`, which declares `minSdk 23`; a lower value in +your app fails the manifest merge at build time. If you are upgrading from +`oidc_android` 1.0.1 or earlier, this is the one change that can break your +build — its floor was 21. +replace `com.my.app` with the scheme of your `redirect_uri` (a reverse-DNS +scheme you own, per [RFC 8252 §7.1](https://datatracker.ietf.org/doc/html/rfc8252#section-7.1)); +your `redirect_uri` then looks like `com.my.app://oauth2redirect`. + +- You do **not** add any ``, `launchMode`, or `taskAffinity` to + `MainActivity` — the plugin handles redirect delivery. > IMPORTANT NOTE -> -> if your `applicationId` contains an underscore (`_`), replace it with a dot (`.`) in the `appAuthRedirectScheme` +> +> if your `applicationId`/scheme contains an underscore (`_`), replace it with a +> dot (`.`) in the `oidcRedirectScheme` (schemes can't contain underscores). + +> **Upgrading from a flutter_appauth-based setup?** Delete the old +> `appAuthRedirectScheme` placeholder and any redirect `` you +> hand-added to `MainActivity` — `package:oidc` no longer depends on +> `flutter_appauth` on any platform, so they are no longer used. + +> **HTTPS redirect (App Links)?** A `https://` `redirect_uri` additionally needs +> a verified [Android App Link](https://developer.android.com/training/app-links/verify-android-applinks) +> (host an `assetlinks.json`). Custom schemes need no verification and are the +> simpler choice for most apps. ### flutter_secure_storage setup [Source](https://pub.dev/packages/flutter_secure_storage#configure-android-version) -1. In `android/app/build.gradle` set `minSdkVersion` to >= 18. +1. `flutter_secure_storage` needs `minSdkVersion` >= 18, which the 23 required + above already satisfies — no further change if you set it. ```diff defaultConfig { applicationId "com.my_app" - minSdkVersion flutter.minSdkVersion - + minSdkVersion 21 + + minSdkVersion 23 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName manifestPlaceholders += [ - 'appAuthRedirectScheme': 'com.my.app' + 'oidcRedirectScheme': 'com.my.app' ] } ``` @@ -101,25 +141,52 @@ replace `com.my.app` with your `applicationId` just do `flutter run` from your terminal (not IDE) once, and it will ask you to enable it (press y). -## iOS/macOS +## iOS + +### Redirect handling (ASWebAuthenticationSession) + +`oidc_darwin` (iOS) uses the system `ASWebAuthenticationSession`, which +registers your `redirect_uri` scheme **at runtime**. You do **not** need a +`CFBundleURLTypes` / `CFBundleURLSchemes` entry in `ios/Info.plist` for the OIDC +redirect, and you do not add any third-party SDK. + +Requirements: + +- **iOS 13.0+** — set the deployment target accordingly in Xcode and in + `ios/Podfile` (`platform :ios, '13.0'` or higher). +- For an `https`/Universal-Link `redirect_uri` (optional, iOS 17.4+), your app + must declare an [Associated Domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains) + entitlement. Custom schemes need no entitlement and are the simpler choice. + +## macOS + +### Redirect handling (ASWebAuthenticationSession) + +`oidc_darwin` (macOS) uses the system `ASWebAuthenticationSession` (the same +first-party approach as iOS — no third-party SDK), which registers your +`redirect_uri` scheme **at runtime**. You do **not** need a `CFBundleURLTypes` / +`CFBundleURLSchemes` entry in `macos/Info.plist` for the OIDC redirect. -### AppAuth setup -Append the following to your `ios/Info.plist` and `macos/Info.plist` file +Requirements: + +- **macOS 10.15+** — set the deployment target accordingly in Xcode and in + `macos/Podfile` (`platform :osx, '10.15'` or higher). + +### App Sandbox network entitlement (macOS) + +macOS runs under the App Sandbox, which **blocks outbound network by default**. +The OIDC flow needs network access, so add the network-client entitlement to +**both** `macos/Runner/DebugProfile.entitlements` **and** +`macos/Runner/Release.entitlements`: ```xml -CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - com.my.app - - - +com.apple.security.network.client + ``` -replace `com.my.app` with your application id + +(For signed/notarized distribution, the +[Hardened Runtime](https://docs.flutter.dev/platform-integration/macos/building#hardened-runtime) +is required and is compatible with these entitlements.) ### flutter_secure_storage setup for macos @@ -162,7 +229,26 @@ Note how `frontChannelLogoutUri` needs `requestType=front-channel-logout` for th you will have to register these urls with the openid provider first, depending on your configuration. -also the html page is completely customizable, but it's preferred to leave the javascript part as is, since it's well-integrated with the plugin. +also the html page is completely customizable, but it's preferred to leave the javascript part as is, since it's well-integrated with the plugin. All user-visible copy lives in a single `messages` object near the top of the script and is safe to translate. + +#### How the page talks to the app + +The page and the app exchange messages over a `BroadcastChannel`: + +- on load, the page posts the incoming redirect to the app as a small JSON envelope: `{"v":2,"type":"redirect","uri":""}`; +- once the app has processed it (the token exchange finished — or failed), the app posts an acknowledgement back: `{"v":2,"type":"ack","status":"ok"|"error","message":""}`. + +The page only shows a **success** state after an `ok` ack — so it no longer claims "Operation Successful" when the app-side exchange actually failed. It shows an **error** state on an `error` ack, or when the provider returned an error directly in the redirect (`error`/`error_description`/`error_uri`, per [RFC 6749 §4.1.2.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)). If no ack arrives within ~10s (e.g. an older app version that never acks), or the browser refuses to close a tab the script didn't open (email-link tabs), it falls back to a neutral "you can now close this tab" state. + +The app still accepts the older bare-URL message, so an outdated copy of the page keeps working after you upgrade the package. The reverse does not hold: this v2 page needs the matching (v2-aware) package version. + +#### Upgrading + +`redirect.html` is copied into your own project, not shipped by the package, so `flutter pub upgrade` never updates it. When you upgrade `oidc`, **re-copy the page** from the example so the app and the page stay on the same wire version. + +#### Troubleshooting + +- **The page never loads (blank tab, or your host/proxy's `502`/`504`).** None of the javascript above can run if the `redirect_uri` doesn't actually serve this page. Make sure your host or reverse proxy serves `redirect.html` at the exact `redirect_uri` path in every environment. While that tab is broken the app receives nothing, so the pending login stays unresolved — return to the app to cancel it (or let it time out) and retry once the page is served correctly. [Read more](oidc-usage.md) @@ -219,6 +305,3 @@ Apart from `libsecret` you also need a keyring service, for that you need either [flutter_secure_storage_link]: https://pub.dev/packages/flutter_secure_storage [flutter_secure_storage_image]: https://img.shields.io/badge/package-flutter__secure__storage-0175C2?logo=dart&logoColor=white - -[flutter_appauth_link]: https://pub.dev/packages/flutter_appauth -[flutter_appauth_image]: https://img.shields.io/badge/package-flutter__appauth-0175C2?logo=dart&logoColor=white \ No newline at end of file diff --git a/docs/oidc-offline-auth.md b/docs/oidc-offline-auth.md new file mode 100644 index 00000000..be2bf59f --- /dev/null +++ b/docs/oidc-offline-auth.md @@ -0,0 +1,455 @@ +# Offline Authentication Guide + +## Overview + +Offline authentication allows your application to keep users authenticated even when the device cannot reach the authentication server. This is particularly useful for: + +- Mobile applications with unreliable network connectivity +- Applications that need to work in areas with poor internet +- Providing a better user experience during temporary network outages +- Desktop applications that may lose network connectivity + +## Enabling Offline Authentication + +Offline authentication is **disabled by default** for security reasons. To enable it, set `supportOfflineAuth` to `true` in your `OidcUserManagerSettings`: + +```dart +final manager = OidcUserManager.lazy( + discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse('https://your-auth-server.com'), + ), + clientCredentials: const OidcClientAuthentication.none( + clientId: 'your-client-id', + ), + store: OidcDefaultStore(), + settings: OidcUserManagerSettings( + redirectUri: Uri.parse('your-redirect-uri'), + scope: ['openid', 'profile', 'email', 'offline_access'], + supportOfflineAuth: true, // Enable offline authentication + ), +); +``` + +### Tuning Refresh Timing + +Two callbacks help you control when the manager refreshes tokens: + +- `refreshBefore` decides how early to refresh **before** a token expires. +- `offlineRefreshRetryDelay` decides how long to wait **between retry attempts** while offline. + +Both callbacks provide sensible defaults (refresh 1 minute before expiry and exponential backoff retries: 30s → 1m → 2m → 4m → 5m). Override them to match your UX: + +```dart +settings: OidcUserManagerSettings( + supportOfflineAuth: true, + refreshBefore: (token) { + // Refresh five minutes before expiry during normal operation + return const Duration(minutes: 5); + }, + offlineRefreshRetryDelay: (consecutiveFailures) { + // Retry aggressively while offline: 15s, 30s, 45s, then cap at 1 minute + final delay = Duration(seconds: 15 * consecutiveFailures); + return delay > const Duration(minutes: 1) + ? const Duration(minutes: 1) + : delay; + }, +), +``` + +Shorter retry windows make the UI respond faster when connectivity returns; longer windows reduce battery and server load. Pick values that balance responsiveness and resource usage for your app. + +## How Offline Authentication Works + +### 1. Normal Authentication Flow + +When network is available: + +1. User authenticates with the identity provider +2. Tokens (ID token, access token, refresh token) are stored securely +3. Token metadata and userinfo are cached +4. Discovery document is cached for offline use + +### 2. Offline Mode + +When the server is unreachable: + +1. The app attempts token refresh +2. If refresh fails due to network/server errors, offline mode activates +3. Cached tokens are used even if expired +4. User remains authenticated with cached data +5. An `OidcOfflineModeEnteredEvent` is emitted + +### 3. Network Recovery + +When connectivity is restored: + +1. The app automatically attempts to refresh tokens +2. Tokens are validated with the server +3. User data is updated +4. An `OidcOfflineModeExitedEvent` is emitted + +## Monitoring Offline Mode + +### Listening to Events + +Subscribe to offline mode events to update your UI: + +```dart +manager.events().listen((event) { + if (event is OidcOfflineModeEnteredEvent) { + // Show offline indicator in UI + print('Entered offline mode: ${event.reason}'); + print('Using cached token: ${event.currentToken != null}'); + + // Show last sync time + if (event.lastSuccessfulServerContact != null) { + final timeSinceSync = DateTime.now().difference(event.lastSuccessfulServerContact!); + print('Last synced: ${timeSinceSync.inMinutes} minutes ago'); + } + } else if (event is OidcOfflineModeExitedEvent) { + // Hide offline indicator + print('Exited offline mode, network restored: ${event.networkRestored}'); + print('Synced at: ${event.lastSuccessfulServerContact}'); + } else if (event is OidcOfflineAuthWarningEvent) { + // Show warning to user + print('Offline auth warning: ${event.message}'); + } +}); + +// You can also check the last successful server contact at any time: +final lastSync = manager.lastSuccessfulServerContact; +if (lastSync != null) { + print('Last successful server contact: $lastSync'); +} +``` + +### Offline Mode Reasons + +The `OfflineModeReason` enum indicates why offline mode was activated: + +- `networkUnavailable` - Device has no internet connection +- `serverUnavailable` - Authentication server is down or unreachable +- `tokenRefreshFailed` - Token refresh failed due to network/server errors +- `discoveryDocumentUnavailable` - Cannot fetch discovery document +- `userInfoUnavailable` - UserInfo endpoint is offline + +### Warning Types + +The `OfflineAuthWarningType` enum provides security warnings: + +- `usingExpiredToken` - App is using an expired token +- `extendedOfflineDuration` - User has been offline for an extended period +- `tokenValidationSkipped` - Cannot validate token with server +- `staleUserInfo` - User information may be outdated +- `repeatRefreshFailure` - Multiple refresh attempts have failed + +## Error Handling + +### Network vs Authentication Errors + +The offline auth system distinguishes between different error types: + +**Continue in Offline Mode (keepUser logged in):** +- Network unavailable (SocketException) +- Connection timeouts +- Server errors (5xx HTTP status codes) +- SSL/TLS errors + +**Force Logout (authenticationErrors):** +- Invalid credentials (401, 403) +- Invalid grant or token +- Access denied +- Client errors (4xx HTTP status codes) + +### Example Error Handling + +```dart +try { + await manager.loginAuthorizationCodeFlow(); +} on OidcException catch (e) { + if (e.errorResponse != null) { + // Authentication error - user needs to re-authenticate + print('Auth error: ${e.errorResponse!.error}'); + } else { + // Network or other error - user may stay logged in with offline auth + print('Error: ${e.message}'); + } +} +``` + +## Security Considerations + +### ⚠️ Important Security Notes + +1. **Token Expiration**: Expired tokens are accepted in offline mode, which could be a security risk +2. **No Server Validation**: Tokens cannot be validated with the identity provider +3. **Stale Data**: User information and permissions may be outdated +4. **Attack Vectors**: Offline mode may open unexpected security vulnerabilities + +### Best Practices + +#### 1. Implement Maximum Offline Duration + +```dart +// Track when offline mode started +DateTime? offlineModeStart; + +manager.events().listen((event) { + if (event is OidcOfflineModeEnteredEvent) { + offlineModeStart = event.at; + } else if (event is OidcOfflineModeExitedEvent) { + offlineModeStart = null; + } +}); + +// Check offline duration periodically +Timer.periodic(Duration(hours: 1), (timer) { + if (offlineModeStart != null) { + final offlineDuration = DateTime.now().difference(offlineModeStart!); + if (offlineDuration > Duration(days: 7)) { + // Force re-authentication after 7 days offline + await manager.forgetUser(); + // Navigate to login screen + } + } +}); +``` + +#### 2. Show Clear Offline Indicators + +Always inform users when they're using cached authentication: + +```dart +Widget buildAuthStatus() { + return StreamBuilder( + stream: manager.events(), + builder: (context, snapshot) { + if (snapshot.data is OidcOfflineModeEnteredEvent) { + return Banner( + message: 'OFFLINE MODE', + location: BannerLocation.topEnd, + child: YourApp(), + ); + } + return YourApp(); + }, + ); +} +``` + +#### 3. Limit Sensitive Operations + +Restrict certain actions when in offline mode: + +```dart +Future performSensitiveOperation() async { + final user = manager.currentUser; + if (user == null) { + throw Exception('Not authenticated'); + } + + // Check if token is expired + if (user.token.isIdTokenExpired()) { + // Try to refresh + try { + await manager.refreshToken(); + } catch (e) { + // If refresh fails and we're using cached data, deny operation + throw Exception('Cannot perform this operation while offline'); + } + } + + // Proceed with operation +} +``` + +#### 4. Validate Tokens on Network Recovery + +```dart +manager.events().listen((event) async { + if (event is OidcOfflineModeExitedEvent) { + // Network restored - validate token + try { + // This will refresh if needed + await manager.refreshToken(); + } catch (e) { + // Token is no longer valid - force re-authentication + await manager.forgetUser(); + } + } +}); +``` + +#### 5. Use Refresh Tokens + +Always request `offline_access` scope to get refresh tokens: + +```dart +settings: OidcUserManagerSettings( + scope: ['openid', 'profile', 'email', 'offline_access'], + supportOfflineAuth: true, +), +``` + +## Testing Offline Scenarios + +### Simulating Network Failures + +For testing, you can simulate network failures: + +```dart +// 1. Using mock HTTP client +final mockClient = MockClient((request) async { + // Simulate network failure + throw SocketException('Network unreachable'); +}); + +final manager = OidcUserManager.lazy( + httpClient: mockClient, + // ... other settings +); + +// 2. Disconnecting device +// - Enable airplane mode on device +// - Disable WiFi and mobile data +// - Use network link conditioner tools + +// 3. Mocking server failures +final mockClient = MockClient((request) async { + // Simulate server error + return http.Response('Server Error', 503); +}); +``` + +### Testing with Example App + +The example app includes offline mode testing: + +1. Enable offline auth in settings +2. Authenticate normally +3. Toggle "Simulate Offline Mode" switch +4. Observe offline behavior and events + +## Troubleshooting + +### User Gets Logged Out Despite Offline Auth Being Enabled + +**Causes:** +- Authentication error (401, 403, invalid_grant) occurred +- Token was removed due to another error +- supportOfflineAuth was false when error occurred + +**Solution:** +- Check error logs to identify the error type +- Ensure supportOfflineAuth is true before errors occur +- Handle authentication errors separately + +### Offline Mode Not Activating + +**Causes:** +- supportOfflineAuth is set to false +- Error is not network-related (e.g., 401) +- Discovery document was never cached + +**Solution:** +- Verify supportOfflineAuth setting +- Ensure user successfully authenticated at least once +- Check network error types in logs + +### Token Refresh Loops + +**Causes:** +- Refresh token is expired +- Server repeatedly returns errors +- Network is unstable + +**Solution:** +- Implement exponential backoff for refresh attempts +- Check refresh token expiration +- Monitor network stability + +### Stale User Data + +**Causes:** +- Extended offline duration +- UserInfo not being refreshed + +**Solution:** +- Force re-authentication after extended offline period +- Update UI to show data staleness +- Attempt userinfo refresh when network returns + +## API Reference + +### Settings + +```dart +class OidcUserManagerSettings { + /// Whether to support offline authentication. + /// When enabled, expired tokens will NOT be removed if the + /// server can't be contacted. + /// This parameter is disabled by default due to security concerns. + final bool supportOfflineAuth; + + /// Controls the retry delay when automatic refresh attempts fail offline. + /// Receives the number of consecutive failures and returns the + /// delay before the next attempt. Defaults to exponential backoff. + final OidcOfflineRefreshRetryDelayCallback offlineRefreshRetryDelay; +} +``` + +### Events + +```dart +/// Emitted when entering offline mode +class OidcOfflineModeEnteredEvent extends OidcEvent { + final OfflineModeReason reason; + final OidcToken? currentToken; + final DateTime? lastSuccessfulServerContact; +} + +/// Emitted when exiting offline mode +class OidcOfflineModeExitedEvent extends OidcEvent { + final bool networkRestored; + final OidcToken? newToken; + final DateTime? lastSuccessfulServerContact; +} + +/// Emitted for security warnings +class OidcOfflineAuthWarningEvent extends OidcEvent { + final OfflineAuthWarningType warningType; + final String message; + final Duration? tokenExpiredSince; +} +``` + +### Public API + +```dart +class OidcUserManagerBase { + /// Gets the last time the manager successfully communicated with the server. + /// This can be useful for displaying "Last synced" information in the UI. + /// Returns null if no successful server contact has been made yet. + DateTime? get lastSuccessfulServerContact; +} +``` + +### Utility Methods + +```dart +/// Check if error should allow offline mode +bool shouldContinueInOfflineMode({ + required Object error, + required bool supportOfflineAuth, +}); + +/// Categorize error type +OfflineAuthErrorType categorizeError(Object error); +``` + +## Related Documentation + +- [Authentication Flow](./oidc-usage.md) +- [Token Management](./oidc-usage-accesstoken.md) +- [Getting Started](./oidc-getting-started.md) +- [OidcUserManager API](./oidc_core.md) diff --git a/docs/oidc-usage-accesstoken.md b/docs/oidc-usage-accesstoken.md index 70c07cb8..5bcb8fca 100644 --- a/docs/oidc-usage-accesstoken.md +++ b/docs/oidc-usage-accesstoken.md @@ -1,29 +1,73 @@ # Using the access token -`package:oidc`'s role ends once you get the `access_token`/`id_token`, and what you do with it is outside the scope of the package. +`package:oidc` hands you an `access_token`; what you do with it against your own API is outside the scope of the package. However here are common best practices on how you can get and use the `access_token` in different libraries. ## Getting the access token -You can get the access token from the `OidcToken` object, that you can get from the `OidcUser` object. +Use `OidcUserManager.getAccessToken()`. It returns an access token that is guaranteed to stay valid for at least `minValidity` (30 seconds by default), refreshing it first only when it would not be: -You can get the `OidcUser` in 2 ways: +```dart +final accessToken = await userManager.getAccessToken(); +``` + +Three things it does that reading the token yourself does not: + +* **it refreshes only when needed** — a still-fresh token is returned without any network call; +* **it coalesces concurrent callers** — N parallel requests that all find a stale token share ONE refresh-token exchange. This matters against a provider that rotates refresh tokens, which [RFC 9700 §2.2.2](https://www.rfc-editor.org/rfc/rfc9700.html#section-2.2.2) requires for public clients ("Refresh tokens for public clients MUST be sender-constrained or use refresh token rotation"): rotation invalidates the previous refresh token on every exchange, so a second concurrent exchange presents an invalidated token and the server "will revoke the active refresh token" ([§4.14.2](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2)) — logging the user out; +* **it tells you when a login is genuinely required** — via a typed exception rather than an OAuth error string you have to match on. + +```dart +try { + final accessToken = await userManager.getAccessToken(); + // ... send the request +} on OidcInteractionRequiredException { + // The session cannot be renewed silently — start an interactive login. + await userManager.loginAuthorizationCodeFlow(); +} +``` + +| Situation | Result | +| --- | --- | +| No signed-in user | returns `null` (a state, not an error) | +| Token still valid for `minValidity` | returns it unchanged, no network call | +| Token stale, refresh succeeds | returns the NEW access token | +| Token stale, no refresh token / `invalid_grant` | throws `OidcInteractionRequiredException` | +| Refresh failed for a recoverable reason (network, 5xx) | throws `OidcException` with `kind == OidcTokenRefreshFailureKind.transient` | + +Pass `forceRefresh: true` to exchange unconditionally, or a larger `minValidity` when your request may take a while to reach the resource server. + +You can still read the raw token off the user object (`userManager.currentUser?.token.accessToken`, or the `userManager.userChanges()` stream) when you want the current value WITHOUT any freshness guarantee — for display, diagnostics, or when you manage renewal yourself. + +### Silent re-authentication + +When the session has no refresh token — a public web client whose provider does not issue one — `signInSilent()` renews off the provider's own session cookie with a `prompt=none` request in a hidden iframe: + +```dart +final user = await userManager.signInSilent(); +``` + +It tries the refresh-token grant first when one is available, and shares the same in-flight exchange as `getAccessToken()`. It throws `OidcInteractionRequiredException` when the provider answers `login_required` / `interaction_required` / `consent_required` / `account_selection_required`. + +The `prompt=none` half is **web-only**: on native platforms it degrades to the refresh-token path, and even on web the hidden iframe depends on the provider session cookie being readable in a third-party context, which Safari's ITP blocks and Chrome restricts. Prefer a refresh token where your provider can issue one. -1. Reactive way: listening on the `OidcUserManager.userChanges()` stream. -2. Non-Reactive way: `OidcUserManager.currentUser` property. +## Sending the token -So the general idea is that when you are sending a request, you would first get the access token, then add it as a header `Authorization: Bearer access_token`. +The general idea is that when you are sending a request, you first get the access token, then add it as an `Authorization` header. ```dart const kAuthorizationHeader = 'Authorization'; -void tryAppendAccessToken(OidcUserManager userManager, Map headers) { +Future tryAppendAccessToken( + OidcUserManager userManager, + Map headers, +) async { if (headers.containsKey(kAuthorizationHeader)) { // do nothing if header already exists. return; } - final accessToken = userManager.currentUser?.token.accessToken; + final accessToken = await userManager.getAccessToken(); if (accessToken == null) { // do nothing if there is no access token. return; @@ -32,6 +76,50 @@ void tryAppendAccessToken(OidcUserManager userManager, Map head } ``` +### DPoP-protected resources + +When you enabled DPoP (`OidcUserManagerSettings.dpop`), your access token is sender-constrained (RFC 9449) and sending it as a plain `Bearer` throws that guarantee away. Mint a proof for each request with `createDPoPProof` and send the pair — `Authorization: DPoP ` plus a `DPoP: ` header: + +```dart +Future tryAppendDPoPAccessToken( + OidcUserManager userManager, + Uri uri, + String method, + Map headers, +) async { + final accessToken = await userManager.getAccessToken(); + if (accessToken == null) { + return; + } + // Pass the same access token you are about to send, so the proof's `ath` + // claim matches it even if the call above just refreshed. + final proof = await userManager.createDPoPProof( + uri: uri, + method: method, + accessToken: accessToken, + ); + headers[kAuthorizationHeader] = + proof == null ? 'Bearer $accessToken' : 'DPoP $accessToken'; + if (proof != null) { + headers[oidcDPoPHeaderName] = proof; + } +} +``` + +`createDPoPProof` returns `null` when DPoP is not enabled, so the same helper works for both configurations. + +A resource server may demand a nonce (RFC 9449 §8): it answers `401` with `error="use_dpop_nonce"` and a `DPoP-Nonce` header. Feed that nonce back and retry once: + +```dart +final nonce = response.headers[oidcDPoPNonceHeaderName.toLowerCase()]; +if (response.statusCode == 401 && nonce != null) { + userManager.cacheDPoPNonce(uri, nonce); + // mint a fresh proof and send the request again, once. +} +``` + +`userManager.dpopThumbprint` exposes the RFC 7638 thumbprint of the session proof key — the value the provider binds the token to as `cnf.jkt`. + ## package:dio Best practice is to create an [interceptor](https://pub.dev/packages/dio#interceptors) that is aware of the current `OidcUserManager`. @@ -50,8 +138,11 @@ class OidcUserManagerInterceptors extends Interceptor { final OidcUserManager userManager; @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - _tryAppendAccessToken(userManager, options.headers); + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + await tryAppendAccessToken(userManager, options.headers); handler.next(options); } } @@ -83,8 +174,8 @@ class OidcHttpClient extends BaseClient { final OidcUserManager userManager; @override - Future send(BaseRequest request) { - _tryAppendAccessToken(userManager, request.headers); + Future send(BaseRequest request) async { + await tryAppendAccessToken(userManager, request.headers); return originalClient.send(request); } } @@ -99,4 +190,4 @@ final client = OidcHttpClient( ); // client.get(...); // client.post(...); -``` \ No newline at end of file +``` diff --git a/docs/oidc-usage.md b/docs/oidc-usage.md index e29bdc49..c8e76f66 100644 --- a/docs/oidc-usage.md +++ b/docs/oidc-usage.md @@ -6,9 +6,7 @@ all the classes that are exposed by this plugin start with `Oidc*`. ### Construction -The main class this plugin provides, as the name suggests, it manages the current user session. - -> NOTE: you should only maintain a single instance of this class in your app. +`OidcUserManager` is the main class this plugin provides, as the name suggests, it manages the current user session. you have two ways to construct it, depending on the availability of the [Discovery Document](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). @@ -40,6 +38,23 @@ you have two ways to construct it, depending on the availability of the [Discove aside from the discovery document, both constructors share the same parameters: +#### Constructing multiple user managers + +If you want to support multiple identity providers, you can construct multiple `OidcUserManager` instances, each with its own discovery document and `id`. + +```dart +final googleManager = OidcUserManager.lazy( + id: 'google', + discoveryDocumentUri: Uri.parse('https://accounts.google.com/.well-known/openid-configuration'), + //...other parameters. +); +final microsoftManager = OidcUserManager.lazy( + id: 'microsoft', + discoveryDocumentUri: Uri.parse('https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration'), + //...other parameters. +); +``` + #### clientCredentials an `OidcClientAuthentication` describes [how the client authenticates with the identity provider](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication), which has the following constructors: @@ -81,6 +96,41 @@ an `OidcClientAuthentication` describes [how the client authenticates with the i how you create/obtain the jwt is outside the scope of this package. you can use [![package:jose_plus][jose_plus_image]][jose_plus_link] to help you. +6. `.tlsClientAuth` / `.selfSignedTlsClientAuth`: mutual-TLS client authentication ([RFC 8705](https://www.rfc-editor.org/rfc/rfc8705)), using a CA-issued (§2.1) or a self-signed (§2.2) client certificate. Only `client_id` goes into the request; the proof is the certificate on the TLS connection. + + The certificate lives in the **transport**, never in `OidcClientAuthentication`. Build a cert-bearing client with `OidcMtls.createHttpClient` and pass it as the manager's [`httpClient`](#httpclient): + + ```dart + final mtlsClient = OidcMtls.createHttpClient( + certificate: OidcMtlsClientCertificate( + certificateChain: File('client.pem').readAsBytesSync(), // leaf first + privateKey: File('client.key').readAsBytesSync(), + // password: '...', // for an encrypted key / PKCS#12 bundle + ), + // Optional: trust a private CA for the SERVER's certificate. + // trustedCertificates: File('ca.pem').readAsBytesSync(), + ); + + final manager = OidcUserManager.lazy( + discoveryDocumentUri: discoveryUri, + clientCredentials: const OidcClientAuthentication.tlsClientAuth( + clientId: 'my_client_id', + ), + httpClient: mtlsClient, + store: OidcDefaultStore(), + settings: OidcUserManagerSettings( + redirectUri: redirectUri, + // Use the provider's `mtls_endpoint_aliases` when it publishes them. + useMtlsEndpointAliases: true, + ), + ); + ``` + + - **Endpoint aliases (RFC 8705 §5).** With `useMtlsEndpointAliases: true`, every request the client makes directly to the provider — token (all grants), UserInfo, revocation, introspection, PAR, device authorization and dynamic client registration — goes to the alias in `mtls_endpoint_aliases` when the provider publishes one, and to the normal endpoint otherwise. The setting is opt-in and never inferred from the auth method. The authorization, end-session and check-session endpoints are visited by the browser, not called by the client, so their aliases are ignored as §5 requires. `jwks_uri` always uses the top-level value: it is a public read that needs no client certificate. + - **Certificate-bound access tokens (RFC 8705 §3).** The RP has two jobs here: present the certificate at the token endpoint, and present the same certificate when it uses the token. Because the manager sends UserInfo through the same `httpClient`, that is already covered. Call your resource servers with the same `mtlsClient` as well. Checking the token's `cnf` / `x5t#S256` is the resource server's job, not this library's. + - **Dynamic client registration.** A registration response with `token_endpoint_auth_method` set to `tls_client_auth` or `self_signed_tls_client_auth` becomes the matching mTLS credentials. The certificate still comes from `httpClient`. + - **Platforms.** VM, Android, iOS, macOS, Windows and Linux are supported through `dart:io`'s `SecurityContext`. If you already have a `SecurityContext`, you can pass `IOClient(HttpClient(context: context))` directly. **Web is not supported:** browsers give `fetch`/XHR no way to present a client certificate. On web, `OidcMtls.isSupported` is `false`, `OidcMtls.createHttpClient` throws an `UnsupportedError`, and `init()` throws an `UnsupportedError` before any network call when the manager is configured with an mTLS method (or a registration hands it one). + #### store @@ -100,10 +150,31 @@ settings to control the behavior of the instance. Enabling offline auth can be a security risk, as it allows the app to keep tokens that are no longer valid. And it may open up unexpected attack vectors. -- `bool strictJwtVerification = false`: whether JWTs are strictly verified. + !!! Tip + For detailed information about offline authentication, including security best practices, error handling, and implementation examples, see the [Offline Authentication Guide](oidc-offline-auth.md). + +- ID-token (and signed UserInfo / signed discovery `signed_metadata`) signature + verification is **always strict** (fail-closed): a token/response whose + signature cannot be verified is rejected. There is no opt-out setting. + + !!! Info "Key rotation" + A `kid` that isn't in the currently-cached JWKS (e.g. right after the + OP rotates its signing keys) triggers one rate-limited, cache-busting + JWKS refetch before failing, so a routine key rotation does not cause + spurious login failures. + + !!! Warning "HS256 id_tokens (e.g. Auth0's default client signing algorithm)" + Some OPs (Auth0 in particular, for confidential clients by default) + sign id_tokens with `HS256`, a symmetric algorithm keyed by your + `client_secret` rather than a JWKS-published public key. Verifying + these requires passing `clientSecret` on your + `OidcClientAuthentication` — the manager registers it as the HS256 + verification key during `init()`. Without a `clientSecret`, an + HS256-signed id_token cannot be verified and login will fail-closed. - `Uri redirectUri`: the redirect uri that was configured with the provider. - `Uri? postLogoutRedirectUri`: the post logout redirect uri that was configured with the provider. - `Uri? frontChannelLogoutUri`: the uri of the front channel logout flow. this Uri MUST be registered with the OP first. the OP will call this Uri when it wants to logout the user. +- `Future Function(OidcToken token) getIdToken`: pass this function to control how an idToken is fetched from a token response. This can be used to trick the user manager into using a JWT `access_token` as an `id_token` for example. - `frontChannelRequestListeningOptions`: the options to use when listening for front channel logout requests. ```dart frontChannelRequestListeningOptions: OidcFrontChannelRequestListeningOptions( @@ -130,8 +201,9 @@ settings to control the behavior of the instance. - `OidcSessionManagementSettings sessionManagementSettings`: contains settings for the session management spec: - `bool enabled`: (default false) whether to enable checking the session. - - `Duration interval`: (default 5 minutes) how often to check the current user session. + - `Duration interval`: (default 5 seconds) how often to check the current user session. - `bool stopIfErrorReceived`: (default true) whether to stop checking the current user session if the OP sends an `error` message. + - `Duration endSessionConfirmationTimeout`: (default 10 seconds) how long the one-off post-logout `check_session_iframe` probe waits for the OP's answer before reporting `timedOut` (see [Confirming logout at the OP](#confirming-logout-at-the-op-session-management)). It never delays logout itself. - `OidcPlatformSpecificOptions? options`: platform specific options to control auth requests: - `bool allowInsecureConnections`: Whether to allow non-HTTPS endpoints; for `android` 🤖 platform only. @@ -179,6 +251,70 @@ settings to control the behavior of the instance. - `String broadcastChannel`: The broadcast channel to use when receiving messages from the browser; defaults to: `oidc_flutter_web/redirect` > Note: This MUST be the same as the one in your [redirect.html](oidc-getting-started.md/#web) page. +##### Hooks + +`OidcUserManagerHooks? hooks`: a set of hooks that allow you to customize the behavior of the user manager. + +- All hooks inherit `OidcHookMixin`, which is just a marker mixin. +- By default, we support the following hooks: + - `token`: to hook into the token request and response. + - `authorization`: to hook into the authorization request and response. + +To define a hook you can either extend `OidcHookBase`, or use the `OidcHook` class directly. + +###### Example: + +To remove the scope from the `refresh_token` request, you can define a hook like this: + +```dart +OidcUserManagerSettings( + hooks: OidcUserManagerHooks( + token: OidcHook( + modifyRequest: (request) { + if (request.request.grantType == 'refresh_token') { + // Remove the scope from refresh_token requests + request.request.scope = null; + } + return Future.value(request); + }, + ), + ), +), +``` + +You can also group multiple hooks together, for example: + +```dart +hooks: OidcUserManagerHooks( + token: OidcHookGroup( + // Only modifyRequest, modifyResponse hooks can be chained. + hooks: [ + OidcHook( + modifyResponse: (response) { + logger.info( + 'Modifying token response: ${response.src}', + ); + return Future.value(response); + }, + ), + ], + // Only a single executionHook can be defined for the group. + executionHook: OidcHook( + modifyExecution: (request, defaultExecution) async { + logger.info( + 'Executing token request: ${request.request.toMap()}', + ); + final response = await defaultExecution(request); + logger.info( + 'Executed token request: ${response.src}', + ); + return response; + }, + ), + ), +), +``` + ##### Default Auth request parameters These are [parameters that you send with the auth request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest), and you can either define them in the `login*` functions, or define the default values here. @@ -211,6 +347,8 @@ These are [parameters that you send with the auth request](https://openid.net/sp We depend on [![package:http][http_image]][http_link], and you can provide your own http client which will let you intercept and modify request/response behavior. +This is also where mutual-TLS gets its client certificate: pass the client from `OidcMtls.createHttpClient` (see [clientCredentials](#clientcredentials), item 6). + #### keyStore A custom keystore from [![package:jose_plus][jose_plus_image]][jose_plus_link], if you want to maintain your own store. @@ -294,6 +432,22 @@ This can be done by calling `logout` with the following optional parameters: - `options`: platform-specific navigation options, which are the same as `settings.options`. - `extraParameters`: extra parameters to pass to the logout request. +### Session Management + +[OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html) lets the RP poll whether the user's session at the OP is still alive, by embedding `check_session_iframe` in a frame it controls and reading back the OP's `postMessage` answer ("unchanged" / "changed" / "error"). `package:oidc` implements this **on web only** — this is a deliberate, permanent scoping decision, not a gap awaiting a native implementation: + +!!! Note + On native platforms (Android, iOS, macOS, Linux, Windows), [RFC 8252 §8.12](https://datatracker.ietf.org/doc/html/rfc8252#section-8.12) requires login to run in an **external** user-agent (the system browser), never an embedded WebView — so the app has no browser surface of its own to host `check_session_iframe` in. And even an app-controlled WebView created purely to poll the session would not work around this: [OpenID Connect Session Management 1.0 §3.2](https://openid.net/specs/openid-connect-session-1_0.html#RPiframe) computes the answer from the OP session **cookie** held by whichever user-agent is loading the iframe, and that cookie lives in the EXTERNAL browser the §8.12 login happened in — a separate, freshly-created WebView carries no such cookie and could only ever report "changed". + + Detect a session that ended at the OP on native platforms through other signals instead: + + - `OidcTokenRefreshFailedEvent` on `manager.events()` — a terminal automatic token-refresh failure (e.g. `invalid_grant`) surfaces the next time the token is refreshed. + - `OidcUserInfoFailedEvent` on `manager.events()` — a `401` from the userinfo endpoint surfaces the next time it's called. + - Front-Channel Logout / Back-Channel Logout, if your OP can push the logout to the app directly. + - [OpenID Connect Native SSO for Mobile Apps](https://openid.net/specs/openid-connect-native-sso-1_0.html), if several apps from the same vendor need to share a signed-out state. + + None of these are instant the way `check_session_iframe` polling is on web — they surface on the *next* token use, not the moment the OP session actually ends. + ### Listening to currentUser changes Whenever a user logs in, logs out, or a token gets refreshed automatically, an event is added to the `userChanges()` stream. @@ -302,6 +456,20 @@ This is similar to firebase auth, and can be used to track the current session. You can also get access to the current authenticated user via `currentUser` property. +`userChanges()` replays the current user to every new listener, even before `init()` has completed. At that point the current user is still the initial `null`, so a listener attached before `init()` can't tell "not initialized yet" apart from "signed out". + +If you need that distinction, for example because you subscribe before `init()` alongside `events()` (which has to happen before `init()` to observe an `invalid_grant` while the cached session is restored), use `userChangesAfterInit()` instead. It emits nothing until `init()` completes, then emits the settled user, then every later change. Any `null` it emits means signed out: + +```dart +manager.events().listen(handleEvent); +manager.userChangesAfterInit().listen((user) { + // Only runs once init() has completed; null here really means signed out. +}); +await manager.init(); +``` + +If `init()` fails, `userChangesAfterInit()` emits the error and closes. If the manager is disposed before `init()` completes, it closes without emitting. + ### Listening to events Events are an advanced form of user changes, since they occur in more places than the `currentUser` stream, and help the developer hook into every flow. @@ -319,7 +487,100 @@ manager.events().listen((event) { }); ``` +#### Confirming logout at the OP (Session Management) + +When session management is enabled (`sessionManagementSettings.enabled`), the OP advertises a `check_session_iframe`, and the signed-in session has a `session_state`, `logout()` asks the OP's `check_session_iframe` once more about the session it just ended, after the OP's end-session response arrives (including a web `samePage` logout resumed on the reloaded page). The answer is reported as an `OidcEndSessionConfirmationEvent`: + +| `outcome` | meaning | +| --- | --- | +| `changed` | the expected answer after a successful logout, but not proof of it (see below). | +| `unchanged` | the OP still considers the session alive, so the OP-side logout did not take, e.g. the End-User declined to log out of the OP ([RP-Initiated Logout 1.0 §2](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout) lets the OP ask). This app is still logged out locally. | +| `error` | the OP iframe answered `error` (or an unknown value), or the probe failed. | +| `timedOut` | no answer within `sessionManagementSettings.endSessionConfirmationTimeout`. | + +Only `unchanged` is a reliable signal. `changed` also shows up when the logout did not happen: when the browser blocks third-party cookies or storage (Safari and Firefox block or partition the OP iframe's cookies by default), cookie-based OPs "might then return changed for every single call" ([Session Management 1.0 §5.1](https://openid.net/specs/openid-connect-session-1_0.html#ThirdPartyContent)), and changes to unrelated sessions can produce false positives ([§3.2](https://openid.net/specs/openid-connect-session-1_0.html#OPiframe)). + +The check runs in the background: the user is forgotten and `userChanges()` emits `null` without waiting for it, so the event usually arrives after that. The manager does not act on the outcome (it does not re-authenticate with `prompt=none`, which would sign the user back in); what to do, for example telling the user they are still signed in at the OP, is up to the app. The event is not emitted on platforms without session monitoring (everything except web), or if the manager is disposed or a new session starts before the OP answers. + +On web with the `samePage` navigation mode, the end-session response is handled during `init()` on the reloaded page, so the event is emitted there: subscribe to `events()` before calling `init()` to receive it. + +```dart +manager.events().listen((event) { + if (event case OidcEndSessionConfirmationEvent( + outcome: OidcEndSessionConfirmationOutcome.unchanged, + )) { + // Logged out of this app, but still signed in at the OP. + } +}); +``` + +### Revoking tokens + +The `OidcUserManager` provides methods to revoke tokens at the authorization server, making them invalid for future use. +#### revokeAccessToken + +Revokes the current user's access token, making it invalid for accessing protected resources. + +```dart +// Revoke current user's access token and forget user +await manager.revokeAccessToken(); + +// Revoke specific token without forgetting user +await manager.revokeAccessToken( + overrideAccessToken: 'specific_token', + forgetUser: false, +); + +// Revoke with custom headers +await manager.revokeAccessToken( + headers: {'Custom-Header': 'value'}, + extraBodyFields: {'custom_param': 'value'}, +); +``` + +**Parameters:** +- `discoveryDocumentOverride`: Optional discovery document to use instead of the default +- `options`: Platform-specific options for the revocation request +- `forgetUser`: Whether to forget the current user after successful revocation (defaults to `true`) +- `overrideAccessToken`: Specific access token to revoke instead of the current user's token +- `revocationEndpointOverride`: Custom revocation endpoint URL to use +- `extraBodyFields`: Additional fields to include in the revocation request body +- `headers`: Additional HTTP headers to include in the request + +#### revokeRefreshToken + +Revokes the current user's refresh token, making it invalid for obtaining new access tokens. + +```dart +// Revoke current user's refresh token +await manager.revokeRefreshToken(); + +// Revoke specific refresh token with custom endpoint +await manager.revokeRefreshToken( + overrideRefreshToken: 'specific_refresh_token', + revocationEndpointOverride: Uri.parse('https://custom.revoke.endpoint'), +); +``` + +**Parameters:** +- `discoveryDocumentOverride`: Optional discovery document to use instead of the default +- `options`: Platform-specific options for the revocation request +- `forgetUser`: Whether to forget the current user after successful revocation (defaults to `true`) +- `overrideRefreshToken`: Specific refresh token to revoke instead of the current user's token +- `revocationEndpointOverride`: Custom revocation endpoint URL to use +- `extraBodyFields`: Additional fields to include in the revocation request body +- `headers`: Additional HTTP headers to include in the request + +**Behavior:** +- Both methods return early if no current user exists +- Both methods return early if the respective token is not available to revoke +- Both methods return early if the authorization server doesn't provide a revocation endpoint +- Both methods call `forgetUser()` automatically after successful revocation when `forgetUser` is `true` +- Both methods use the hooks system to allow customization of the revocation process + +!!! Note + Token revocation is a best-effort operation. Some authorization servers may not support token revocation, in which case these methods will return without error. Always check your authorization server's capabilities before relying on token revocation. ### Refreshing the token manually diff --git a/docs/oidc_cli.md b/docs/oidc_cli.md new file mode 100644 index 00000000..10c7c454 --- /dev/null +++ b/docs/oidc_cli.md @@ -0,0 +1,223 @@ + +# [![package:oidc_cli][package_image]][package_link] + +`oidc_cli` is a small provider-agnostic CLI for authenticating against an OpenID Connect (OIDC) provider. + +It’s meant for local development and scripting. + +## Install + +From pub.dev: + +```bash title="Install from pub.dev" +dart pub global activate oidc_cli +``` + +From this repo: + +```bash title="Activate from this repo" +dart run melos run oidc:activate +``` + +```bash title="Activate directly (path)" +dart pub global activate --source path packages/oidc_cli +``` + +The executable is: + +- `oidc` + +## Quickstart + +```bash title="Login → status → token → logout" +oidc login interactive --issuer https://issuer.example.com --client-id your-client-id +oidc status +oidc token get +oidc logout +``` + +## Configuration + +### Global options + +| Option | Meaning | +| --- | --- | +| `--version`, `-v` | Print version and exit. | +| `--[no-]verbose` | Verbose logging. | +| `--store ` | Use a specific store file. | + +### Store override + +| Mechanism | Notes | +| --- | --- | +| `--store ` | Highest priority. | +| `OIDC_CLI_STORE` | Used when `--store` isn’t set. | +| default | `~/.oidc_cli/store.json` | + +## Commands + +### `login` + +Logs in and persists provider configuration (issuer/client/scopes/etc.) to the store. + +#### `login interactive` + +Authorization Code flow with a local loopback redirect. + +```bash title="Interactive login" +oidc login interactive --issuer --client-id [options] +``` + +| Option | Required | Default | Notes | +| --- | --- | --- | --- | +| `--issuer`, `-i` | yes | | Issuer URL. | +| `--client-id`, `-c` | yes | | Client ID. | +| `--client-secret`, `-s` | no | | Client secret. | +| `--scopes`, `-S` | no | `openid profile email offline_access` | Space-separated scopes. | +| `--redirect-port`, `-p` | no | `3000` | Loopback listener port. | +| `--[no-]auto-refresh` | no | enabled | Refresh if expiring soon. | +| `--add-to-dart-pub ` | no | | Runs `dart pub token add` after login. | + +??? tip "If the browser doesn’t open" + The CLI prints a URL; copy/paste it into a browser. + +#### `login password` + +Resource Owner Password Credentials grant. + +```bash title="Password login" +oidc login password --username --password [options] +``` + +!!! warning "Provider support varies" + Many providers disable this grant. Prefer `login interactive` or `login device`. + +| Option | Required | Default | Notes | +| --- | --- | --- | --- | +| `--username`, `-u` | yes | | Username. | +| `--password` | yes | | Password. | +| `--issuer`, `-i` | no | (saved) | Falls back to `config.issuer`. | +| `--client-id`, `-c` | no | (saved) | Falls back to `config.clientId`. | +| `--client-secret`, `-s` | no | (saved) | Falls back to `config.clientSecret`. | +| `--scopes`, `-S` | no | `openid profile email offline_access` | Space-separated scopes. | +| `--redirect-port`, `-p` | no | `3000` | Saved for future interactive logins; not used here. | +| `--[no-]auto-refresh` | no | enabled | Refresh if expiring soon. | +| `--add-to-dart-pub ` | no | | Runs `dart pub token add` after login. | + +#### `login device` + +Device Authorization Grant (`device_code`). + +```bash title="Device login" +oidc login device [options] +``` + +| Option | Required | Default | Notes | +| --- | --- | --- | --- | +| `--issuer`, `-i` | no | (saved) | Falls back to `config.issuer`. | +| `--client-id`, `-c` | no | (saved) | Falls back to `config.clientId`. | +| `--client-secret`, `-s` | no | | Client secret. | +| `--scopes`, `-S` | no | `openid profile email offline_access` | Space-separated scopes. | +| `--add-to-dart-pub ` | no | | Runs `dart pub token add` after login. | + +### `token` + +#### `token get` + +Print the access token (refreshes if expiring soon). + +```bash title="Get token" +oidc token get [--no-auto-refresh] +``` + +| Option | Default | Notes | +| --- | --- | --- | +| `--[no-]auto-refresh` | enabled | Refresh if expiring soon. | + +#### `token refresh` + +Force a refresh and print the new access token. + +```bash title="Refresh token" +oidc token refresh +``` + +### `status` + +```bash title="Show current status" +oidc status +``` + +### `logout` + +```bash title="Logout" +oidc logout +``` + +??? note "What logout does" + It attempts token revocation when possible, then clears the local session. + +### `dart` / `flutter` (pub proxy) + +Proxy `dart pub ...` / `flutter pub ...` while keeping a hosted pub token up-to-date. + +```bash title="Proxy pub commands" +oidc dart pub +oidc flutter pub +``` + +| Option | Default | Notes | +| --- | --- | --- | +| `--hosted-url ` | (saved) | Overrides saved `config.hostedUrl`. | +| `--[no-]auto-refresh` | enabled | Refresh token before using it for pub. | + +??? note "No surprises" + If the proxied args are explicitly `pub token add`, the proxy won’t inject a token. + +???+ note "Less common commands" + **`discovery`** (print discovery JSON) + + ```bash + oidc discovery [--issuer | --well-known ] + ``` + + **`store-path`** (print resolved store path) + + ```bash + oidc store-path + ``` + + **`update`** (update the CLI) + + ```bash + oidc update + ``` + + **`completion`** (shell completion) + + ```bash + oidc completion --help + ``` + +## Output (for scripts) + +??? info "Token output" + - `oidc token get`, `oidc token refresh`, and `oidc login device` print the raw token. + - `oidc login interactive` and `oidc login password` print `Access Token: ...`. + +## Store & security + +!!! warning "Treat the store like a password" + The store is a plain JSON file and may contain refresh tokens. + +## Troubleshooting + +!!! tip "Common fixes" + - Browser didn’t open automatically: copy/paste the printed URL. + - Redirect rejected: ensure `http://localhost:` is allowed/registered by your provider. + - “No active session”: make sure you’re using the same store (`--store` / `OIDC_CLI_STORE`). + +--- + +[package_link]: https://pub.dev/packages/oidc_cli +[package_image]: https://img.shields.io/badge/package-oidc__cli-0175C2?logo=dart&logoColor=white diff --git a/docs/oidc_core.md b/docs/oidc_core.md index 815e60ce..05fdf32b 100644 --- a/docs/oidc_core.md +++ b/docs/oidc_core.md @@ -63,6 +63,10 @@ The base class for all events, this contains an `at` property that stores when t Occurs before a user is forgotten, either via `forgetUser()` or via `logout()`. +### OidcEndSessionConfirmationEvent + +Occurs after an RP-initiated `logout()` when session management is enabled, the OP advertises a `check_session_iframe` and the ended session had a `session_state`. It carries what the OP's `check_session_iframe` answered for the ended session (OpenID Connect Session Management 1.0 §3.1): `outcome` is `changed` (the expected answer after a successful logout, but not proof of it: blocked third-party cookies can make the iframe answer `changed` for every call, §5.1, and unrelated sessions can cause false positives, §3.2), `unchanged` (the OP session is still alive, so the OP-side logout did not take; the only reliable signal), `error`, or `timedOut` (see `OidcSessionManagementSettings.endSessionConfirmationTimeout`). Logout does not wait for it, and the manager does not act on it. On a resumed web `samePage` logout it is emitted during `init()`, so subscribe to `events()` before calling `init()`. + ## OidcPkcePair you can use this to generate PKCE key pairs. @@ -111,15 +115,21 @@ A wrapper around `OidcToken` that requires the existence of `id_token` to unders to create a user, you should call `fromIdToken` which takes the following parameters: - `OidcToken token`: the source token, this MUST contain `idToken`. -- `strictVerification`: if true, will throw an error if we fail to verify the signature of the JWT id token. -!!! Warning - The current jose package we use is unmaintained, and has multiple problems, so setting `strictVerification: true` might throw random errors. - - you MUST test if it works before using it in production. - - see this issue for more information: [oidc#9](https://github.com/Bdaya-Dev/oidc/issues/9). - -- `keystore`: the store that contains information about the public json web keys. +- `keystore`: the store that contains information about the public json web keys. When provided, signature + verification is **always strict** (fail-closed) — an id_token whose signature cannot be verified throws; + there is no opt-out. When omitted, the id_token is parsed but left unverified. + + !!! Info "Key rotation" + A `kid` absent from the currently-loaded JWKS triggers one rate-limited, cache-busting JWKS refetch + before failing (OpenID Connect Core 1.0 §10.1.1), so a routine signing-key rotation at the OP does not + cause a spurious verification failure. + + !!! Warning "HS256 id_tokens" + Some OPs (e.g. Auth0, for confidential clients by default) sign id_tokens with the symmetric `HS256` + algorithm, keyed by the client secret rather than a JWKS-published key. Verifying these requires an + `oct` key derived from the client secret to be present in `keystore` — `OidcUserManagerBase.init()` + does this automatically when `clientCredentials` carries a `clientSecret`. Calling `fromIdToken` + directly (bypassing the manager) must add that key itself. - `attributes`: extra attributes to put with the user for customization. - `userInfo`: the response from the `/userinfo` endpoint. @@ -135,6 +145,140 @@ this is done using these functions: - `setAttributes`: merges input attributes with existing attributes. - `clearAttributes`: removes all attributes. +When a response for a signed-in user has no `id_token` (typically a refresh, which OpenID Connect Core §12.2 allows), +`replaceToken` keeps the previous id_token and records that in the token: `OidcToken.idTokenRetainedFromPriorResponse` is +`true`. The flag is persisted with the token, and `OidcToken.fromResponse` never takes it from a server response. + +## ID token validation + +`OidcUserManagerBase.validateUser` checks an id_token's claims and returns the list of problems it found. Which checks apply +can depend on the flow that produced the token, so `validateUser`, `validateAndSaveUser` and `createUserFromToken` take an +`OidcIdTokenValidationContext`: + +```dart +const OidcIdTokenValidationContext({ + OidcIdTokenSource source = OidcIdTokenSource.tokenEndpoint, + String? authorizationCode, // the code returned with the id_token, checked against c_hash + String? accessToken, // the access_token returned with the id_token, checked against at_hash + Duration? maxAge, // the max_age that was requested, checked against auth_time +}); +``` + +The hash rules are judged from the response's own tokens. When a user is already signed in, the user built for a new +response keeps the previous session's access_token if the response returned none, so the access_token to check is passed in +the context. For `authorizationEndpoint`, `accessToken: null` means the response returned no access_token (`id_token`, +`code id_token`). For the other sources, `null` falls back to the user's own access_token. `createUserFromToken` fills +`accessToken` in from the response token it is given. + +The `at_hash` and `c_hash` rules per `OidcIdTokenSource`: + +| source | `at_hash` | `c_hash` | +|---|---|---| +| `authorizationEndpoint` (implicit, hybrid front channel) | required when the response returned an access_token, and must match it | required when the response returned a code, and must match it | +| `tokenEndpoint` (code exchange, password, device code) | checked when present | checked when present | +| `refresh` | checked when present, only if the refresh returned a new id_token | same | +| `storedSession` (`init()` revalidating the stored session) | checked when present, unless the stored id_token was retained from an earlier response | same | + +An id_token retained from an earlier response (`idTokenRetainedFromPriorResponse`) was not issued with the current tokens, +so its hashes are never required. A refresh, and the stored session it produced, do not compare them either (§12.2). Any +other response that kept the previous id_token, such as a password re-login whose response has no id_token, still +compares a hash the kept id_token carries, as before. + +### When validation fails + +`validateAndSaveUser` returns `null` and clears the pending nonce. It does not touch the stored session or `currentUser`, so +a login or refresh response that fails validation leaves an already signed-in session in place. + +A stored session that fails revalidation during `init()` is handled by the cached-token loader. It calls +`OidcUserManagerSettings.shouldRemoveInvalidToken` (by default the session is removed unless `supportOfflineAuth` is on). If +the policy keeps it, the token stays in the store. Either way no user is signed in with it: if `init()` had already signed a +user in with that session (by refreshing it, or by cache-first restoring it), that user is signed out again. + +### Migrating from 3.x + +These are breaking changes for code that subclasses `OidcUserManagerBase`: + +- `validateUser({user, metadata, authorizationCode, maxAge})` is now `validateUser({user, metadata, context})`. +- `validateAndSaveUser({user, metadata, authorizationCode, maxAge, reactToUserInfoUnauthorized})` is now + `validateAndSaveUser({user, metadata, context, reactToUserInfoUnauthorized})`. +- `createUserFromToken({..., authorizationCode, maxAge, ...})` is now `createUserFromToken({..., context, ...})`. +- `OidcIdTokenValidationContext.accessToken` is the access_token returned with the id_token. For the + `authorizationEndpoint` source, `at_hash` is checked against it and never against the validated user's token. + +Pass the old arguments through the context: + +```dart +// before +validateUser(user: user, metadata: metadata, authorizationCode: code, maxAge: maxAge); +// after +validateUser( + user: user, + metadata: metadata, + context: OidcIdTokenValidationContext(authorizationCode: code, maxAge: maxAge), +); +``` + +If you call `validateUser` or `validateAndSaveUser` for an id_token that came from the authorization endpoint, pass +`source: OidcIdTokenSource.authorizationEndpoint` and the access_token of that response as `accessToken` to get the +required-hash checks. Without `accessToken`, that source treats the response as having returned no access_token. + +Behavior changes: + +- `validateAndSaveUser` no longer deletes the stored token, user info and attributes when validation fails. If your subclass + relied on that, remove the session yourself (for example with `forgetUser()`). +- `shouldRemoveInvalidToken` returning `false` now really keeps the stored token. Before, it had already been deleted. +- An implicit `id_token token` response whose id_token has no `at_hash` is now rejected (OpenID Connect Core §3.2.2.10). +- A refresh that keeps the old id_token no longer fails the `at_hash` check, either right away or when the app restarts. +- `at_hash` is compared with the access_token of the response that returned the id_token, not with one a signed-in user + kept from an earlier response. +- When `init()` refreshes the stored session and the revalidation that follows fails, the refreshed user is now signed out + even if `shouldRemoveInvalidToken` keeps the session in the store. + +## Discovery document issuer validation + +Per OIDC Discovery 1.0 §4.3 / RFC 8414 §3.3, `OidcUserManagerSettings.strictIssuerValidation` (default `true`) rejects a +discovery document whose `issuer` does not match the issuer it was expected to describe. A rejected document is neither +persisted nor kept: after a failed `init()` every flow (including `getAccessToken()`, which used to return `null`) +throws instead of building a request, and a rejected background (cache-first) refresh keeps the previously validated +document and still re-verifies the restored session against it. Set `OidcUserManagerSettings.expectedIssuer` when a provider's issuer cannot be derived +from the discovery URL; `strictIssuerValidation: false` only warns instead of rejecting, for providers `expectedIssuer` +cannot describe either. + +### Azure AD B2C + +With B2C's default "Issuer (iss) claim" setting, the discovery `issuer` is not the discovery URL's prefix: the tenant +domain is replaced by the tenant GUID, the policy segment is dropped, and a trailing slash is added. For example, +`https://fabrikamb2c.b2clogin.com/fabrikamb2c.onmicrosoft.com/B2C_1_susi/v2.0/.well-known/openid-configuration` returns +`"issuer": "https://fabrikamb2c.b2clogin.com/775527ff-9a37-4307-8b3d-cc311f58d925/v2.0/"`. See Microsoft's +[token compatibility settings](https://learn.microsoft.com/azure/active-directory-b2c/tokens-overview#compatibility) and, +for custom policies, [`IssuanceClaimPattern`](https://learn.microsoft.com/azure/active-directory-b2c/jwt-issuer-technical-profile). + +Options, most to least preferred: + +1. Switch the user flow/custom policy's "Issuer (iss) claim" (`IssuanceClaimPattern` in custom policies) to + `AuthorityWithTfp`, and use the matching `/tfp/.../.well-known/openid-configuration` discovery URL. The resulting + issuer (`https:///tfp///v2.0/`) is exactly what that URL derives to, so no `expectedIssuer` + is needed. This is Microsoft's documented option for OpenID Connect Discovery 1.0 compliance. The comparison is + case-sensitive, so the discovery URL's tenant and policy segments must use the casing B2C emits in the issuer + (otherwise set `expectedIssuer`). +2. Keep B2C's default issuer format and set `expectedIssuer` to the actual issuer B2C returns (as in the example above). +3. Last resort: set `strictIssuerValidation: false`. + +### Microsoft Entra ID multi-tenant (`/common`, `/organizations`) + +Entra's multi-tenant discovery `issuer` is a template (`https://login.microsoftonline.com/{tenantid}/v2.0`) rather than a +concrete URL. `OidcUtils.discoveryIssuerMatches` matches that template against an `expectedIssuer` pinned to a concrete +tenant (`https://login.microsoftonline.com//v2.0`), so pinning `expectedIssuer` is all a multi-tenant RP needs; see +[#389](https://github.com/Bdaya-Dev/oidc/issues/389). + +### Other Entra authorities + +| Authority | Discovery `issuer` | What to set | +| --- | --- | --- | +| Tenant by domain name, e.g. `.../contoso.onmicrosoft.com/v2.0` | `https://login.microsoftonline.com//v2.0` | Use the GUID authority, or `expectedIssuer` = that issuer | +| `.../consumers/v2.0` | `https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0` (concrete, not a template) | `expectedIssuer` = that issuer | +| v1 `https://login.microsoftonline.com/common` | `https://sts.windows.net/{tenantid}/` | `expectedIssuer` = `https://sts.windows.net//` | + ## OidcException Most of the errors thrown by this library are of type `OidcException`. diff --git a/docs/oidc_web_core.md b/docs/oidc_web_core.md index de67303b..1a5d4972 100644 --- a/docs/oidc_web_core.md +++ b/docs/oidc_web_core.md @@ -50,7 +50,26 @@ Note how `frontChannelLogoutUri` needs `requestType=front-channel-logout` for th you will have to register these urls with the openid provider first, depending on your configuration. -also the html page is completely customizable, but it's preferred to leave the javascript part as is, since it's well-integrated with the plugin. +also the html page is completely customizable, but it's preferred to leave the javascript part as is, since it's well-integrated with the plugin. All user-visible copy lives in a single `messages` object near the top of the script and is safe to translate. + +#### How the page talks to the app + +The page and the app exchange messages over a `BroadcastChannel`: + +- on load, the page posts the incoming redirect to the app as a small JSON envelope: `{"v":2,"type":"redirect","uri":""}`; +- once the app has processed it (the token exchange finished — or failed), the app posts an acknowledgement back: `{"v":2,"type":"ack","status":"ok"|"error","message":""}`. + +The page only shows a **success** state after an `ok` ack — so it no longer claims "Operation Successful" when the app-side exchange actually failed. It shows an **error** state on an `error` ack, or when the provider returned an error directly in the redirect (`error`/`error_description`/`error_uri`, per [RFC 6749 §4.1.2.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)). If no ack arrives within ~10s (e.g. an older app version that never acks), or the browser refuses to close a tab the script didn't open (email-link tabs), it falls back to a neutral "you can now close this tab" state. + +The app still accepts the older bare-URL message, so an outdated copy of the page keeps working after you upgrade the package. The reverse does not hold: this v2 page needs the matching (v2-aware) package version. + +#### Upgrading + +`redirect.html` is copied into your own project, not shipped by the package, so `dart pub upgrade` never updates it. When you upgrade `oidc_web_core`, **re-copy the page** from the example so the app and the page stay on the same wire version. + +#### Troubleshooting + +- **The page never loads (blank tab, or your host/proxy's `502`/`504`).** None of the javascript above can run if the `redirect_uri` doesn't actually serve this page. Make sure your host or reverse proxy serves `redirect.html` at the exact `redirect_uri` path in every environment. While that tab is broken the app receives nothing, so the pending login stays unresolved — return to the app to cancel it (or let it time out) and retry once the page is served correctly. ## Usage diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..5b2368c2 --- /dev/null +++ b/firebase.json @@ -0,0 +1,39 @@ +{ + "hosting": [ + { + "target": "oidc-flutter-example", + "public": "dist/oidc-example", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "headers": [ + { + "source": "**/*.wasm", + "headers": [ + { + "key": "Content-Type", + "value": "application/wasm" + } + ] + } + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + { + "target": "oidc-flutter-docs", + "public": "dist/oidc-docs", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ] + } + ] +} diff --git a/logo.png b/logo.png new file mode 100644 index 00000000..f4083ba0 Binary files /dev/null and b/logo.png differ diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..b6fa136f --- /dev/null +++ b/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index f1962e48..00000000 --- a/melos.yaml +++ /dev/null @@ -1,182 +0,0 @@ -name: oidc -repository: https://github.com/Bdaya-Dev/oidc -packages: - - packages/* - - packages/*/example - -command: - bootstrap: - runPubGetInParallel: false - clean: - hooks: - post: | - melos exec --flutter -- "flutter clean" && - rm -rf coverage && - rm -rf pubspec.lock && - melos exec --dir-exists coverage -- "rm -rf coverage" -scripts: - pana: - run: melos exec -c 1 "pana" - description: Run pana on all the projects - packageFilters: - noPrivate: true - generate:dart: - run: melos exec -c 1 --depends-on="build_runner" -- "dart run build_runner build --delete-conflicting-outputs" - description: Build all generated files for Dart packages in this project. - - analyze: # ------ taken from cfug/dio ------------ - description: Analyze all packages - exec: dart analyze - format: - description: Format check all packages - exec: dart format --set-exit-if-changed . - format:fix: - description: Format all packages - exec: dart format . - - test: - name: All tests - run: | - melos run test:vm - TEST_PLATFORM=chrome melos run test:web - TEST_PLATFORM=firefox melos run test:web - melos run test:flutter - test:vm: - name: Dart VM tests - exec: | - if [ "$TARGET_DART_SDK" = "min" ]; then - dart test --chain-stack-traces - else - dart test --coverage=coverage/vm --chain-stack-traces - fi - packageFilters: - flutter: false - dirExists: test - ignore: - - "*web*" - test:web: - name: Dart Web tests - run: | - melos run test:web:chrome - melos run test:web:firefox - test:web:chrome: - name: Dart Web tests in chrome - run: melos run test:web:single - env: - TEST_PLATFORM: chrome - WITH_WASM: true - test:web:firefox: - name: Dart Web tests in firefox - run: melos run test:web:single - env: - TEST_PLATFORM: firefox - WITH_WASM: false - test:web:single: - name: Dart Web tests in a browser - exec: | - if [ "$TARGET_DART_SDK" = "min" ]; then - dart test --platform ${TEST_PLATFORM} --chain-stack-traces - else - dart test --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces - if [ "$WITH_WASM" = "true" ]; then - dart test --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --chain-stack-traces --compiler=dart2wasm - fi - fi - packageFilters: - dirExists: test - flutter: false - remove_dart_test_yaml: - name: Remove dart_test.yaml - run: find . -type f -name 'dart_test.yaml' -delete - test:flutter: - name: Flutter tests - exec: flutter test --coverage --branch-coverage - packageFilters: - flutter: true - dirExists: test - ignore: - - "*example*" - integration_test:android: - name: Flutter Android Integration tests - exec: flutter test integration_test --coverage --coverage-package oidc* --branch-coverage -d android --coverage-path coverage/android-coverage.info --dart-define=CI=true - packageFilters: - flutter: true - dirExists: - - integration_test - - android - integration_test:ios: - name: Flutter iOs Integration tests - exec: flutter test integration_test -v --coverage --coverage-package oidc* --branch-coverage --ignore-timeouts -d iPhone --coverage-path coverage/ios-coverage.info --dart-define=CI=true - packageFilters: - flutter: true - dirExists: - - integration_test - - ios - integration_test:macos: - name: Flutter MacOs Integration tests - exec: flutter test integration_test --coverage --branch-coverage --coverage-package oidc* -d macos --coverage-path coverage/macos-coverage.info --dart-define=CI=true - packageFilters: - flutter: true - dirExists: - - integration_test - - macos - integration_test:linux: - name: Flutter Linux Integration tests - exec: xvfb-run flutter test integration_test --coverage --branch-coverage --coverage-package oidc* -d linux --coverage-path coverage/linux-coverage.info --dart-define=CI=true - packageFilters: - flutter: true - dirExists: - - integration_test - - linux - integration_test:windows: - name: Flutter Windows Integration tests - exec: flutter test integration_test --coverage --branch-coverage --coverage-package oidc* -d windows --coverage-path coverage/windows-coverage.info --dart-define=CI=true - packageFilters: - flutter: true - dirExists: - - integration_test - - windows - - test:coverage: - name: Run all tests and combine coverage - run: | - melos run test - melos run coverage:format - - upgrade:dart: - name: Upgrade Dart package deps - exec: dart pub upgrade - packageFilters: - flutter: false - - upgrade:flutter: - name: Upgrade Flutter package deps - exec: flutter pub upgrade - packageFilters: - flutter: true - - coverage:clean: - name: Clear coverage - exec: rm -rf coverage - - coverage:format: - name: Format coverage - run: | - dart pub global activate coverage - melos run coverage:format:package - - coverage:format:package: - name: Format coverage for each package - exec: dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib - packageFilters: - dirExists: coverage - - coverage:combine: - name: Combine & convert coverage report - run: | - rm -rf coverage - dart pub global activate combine_coverage - dart pub global activate remove_from_coverage - melos run coverage:format - dart pub global run combine_coverage --repo-path=$pwd - dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' diff --git a/migrate-to-workspace.ps1 b/migrate-to-workspace.ps1 new file mode 100644 index 00000000..8689796d --- /dev/null +++ b/migrate-to-workspace.ps1 @@ -0,0 +1,80 @@ +# Migrates the root pubspec.yaml to use Dart/Flutter workspace support via yq. +param( + [Parameter(Mandatory = $false)] + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '.')).Path, + + [Parameter(Mandatory = $false)] + [string]$PackagesDir = 'packages', + + [Parameter(Mandatory = $false)] + [string]$SdkConstraint = '^3.6.0', + + [Parameter(Mandatory = $false)] + [string]$FlutterConstraint = '>=3.24.0', + + [Parameter(Mandatory = $false)] + [string]$YqPath = 'yq' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-RequiredCommand { + param([string]$Name) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' was not found in PATH." + } +} + +Resolve-RequiredCommand -Name $YqPath + +$pubspecPath = Join-Path $RepoRoot 'pubspec.yaml' +if (-not (Test-Path -Path $pubspecPath -PathType Leaf)) { + throw "Could not find pubspec.yaml at '$pubspecPath'." +} + +$packagesRoot = Join-Path $RepoRoot $PackagesDir +if (-not (Test-Path -Path $packagesRoot -PathType Container)) { + throw "Could not find packages directory at '$packagesRoot'." +} + +$workspaceMembers = Get-ChildItem -Path $packagesRoot -Filter 'pubspec.yaml' -Recurse -File | + Where-Object { + $dir = $_.DirectoryName + $dir -and ($dir -notmatch '\\.dart_tool\\') -and ($dir -notmatch '\\build\\') + } | + ForEach-Object { + $relative = [System.IO.Path]::GetRelativePath($RepoRoot, $_.DirectoryName) + ($relative -replace '\\', '/') + } | + Sort-Object -Unique + +if (-not $workspaceMembers) { + throw "No workspace members were discovered under '$packagesRoot'." +} + +$tempFile = [System.IO.Path]::GetTempFileName() +try { + Set-Content -Path $tempFile -Value ($workspaceMembers -join "`n") -Encoding UTF8 + + $tempFilePosix = $tempFile -replace '\\', '/' + + $yqExpr = @" + .publish_to = "none" | + .environment.sdk = "$SdkConstraint" | + .environment.flutter = "$FlutterConstraint" | + .workspace = (load_str("$tempFilePosix") + | split("\n") + | map(sub("\r$"; "")) + | map(select(length > 0))) +"@ + + & $YqPath eval -i $yqExpr $pubspecPath | Out-Null + + Write-Host "Updated '$pubspecPath' with $(($workspaceMembers | Measure-Object).Count) workspace members." + Write-Host "SDK constraint set to $SdkConstraint and Flutter constraint set to $FlutterConstraint." +} +finally { + Remove-Item -Path $tempFile -ErrorAction SilentlyContinue +} diff --git a/mkdocs.yml b/mkdocs.yml index 5389250a..743f6c14 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,8 @@ nav: - 'Getting Started': oidc-getting-started.md - 'Usage': oidc-usage.md - 'Access token usage': 'oidc-usage-accesstoken.md' + - 'Offline Authentication': 'oidc-offline-auth.md' + - 'package:oidc_cli': oidc_cli.md - 'package:oidc_core': oidc_core.md - 'package:oidc_web_core': oidc_web_core.md - 'OidcStore': diff --git a/packages/crypto_keys_plus/.gitignore b/packages/crypto_keys_plus/.gitignore new file mode 100644 index 00000000..ec676462 --- /dev/null +++ b/packages/crypto_keys_plus/.gitignore @@ -0,0 +1,15 @@ +# Files and directories created by pub +.dart_tool/ +.packages +# Remove the following pattern if you wish to check in your lock file +pubspec.lock + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ + +crypto_keys.iml +.vscode/ +.idea/ diff --git a/packages/crypto_keys_plus/CHANGELOG.md b/packages/crypto_keys_plus/CHANGELOG.md new file mode 100644 index 00000000..cca7b330 --- /dev/null +++ b/packages/crypto_keys_plus/CHANGELOG.md @@ -0,0 +1,53 @@ +## 1.0.0 + +> Note: This release has breaking changes. + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(crypto_keys_plus): resolve pub.dev publish dry-run warnings. ([5bee9a89](https://github.com/Bdaya-Dev/oidc/commit/5bee9a8954e4fc5412f20db826aa7fe053eebabb)) + - **FEAT**(crypto): RSASSA-PSS (PS256/384/512) + EdDSA/Ed25519. ([6c5c6741](https://github.com/Bdaya-Dev/oidc/commit/6c5c674149fe0504207c95aefbda7fedfca72ae9)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + +## 0.5.0 + - **UPGRADE**: migrated to pointycastle 4.0.0. + +## 0.4.0 + - **REFACTOR**: remove dependency on quiver. + - **FIX**: return null instead of throwning when seeing an uknown curve/key type... + +## 0.3.0+1 + + - **REFACTOR**: use OAEPEncoding.withSHA256 implementation from pointycastle. ([00510d2e](https://github.com/appsup-dart/crypto_keys/commit/00510d2e3df5b24541230832c162f06c571cd18c)) + - **REFACTOR**: use ECCurve_secp256k1 implementation of pointycastle. ([fa323419](https://github.com/appsup-dart/crypto_keys/commit/fa323419ee9b8512707b7903a43a86d3dca89e70)) + - **FIX**: A256GCM-encrypted content produced by this package not always decrypted correctly by other tools (pull request [#10](https://github.com/appsup-dart/crypto_keys/issues/10) of tallinn1960). ([45d65b35](https://github.com/appsup-dart/crypto_keys/commit/45d65b357e7bf313b64f25cb80bd23c9d9d682e2)) + - **FIX**: add missing ES256K algorithm to list of supported algorithms (pull request [#11](https://github.com/appsup-dart/crypto_keys/issues/11) from muhammadsaddamnur). ([c7e32c67](https://github.com/appsup-dart/crypto_keys/commit/c7e32c67b92953e30a1b791f4572c5d70567b75a)) + + +## 0.3.0 + +- It is now a static error to pass a nullable algorithm parameter to `createVerifier` and `createSigner` + +## 0.2.0 + +- Port to null-safety + +## 0.1.4 + +- Upgraded dependency to Pointy Castle's new 2.0.0 release. + +## 0.1.3 + +- Added support for P-256K curve + +## 0.1.2 + +- Added RSAES-OAEP-256 +- Generate assymetric key pairs + +## 0.1.1 + +- Fix for running on web +- Added RSAES-OAEP + +## 0.1.0 + +- Initial version diff --git a/packages/crypto_keys_plus/LICENSE b/packages/crypto_keys_plus/LICENSE new file mode 100644 index 00000000..d59994b1 --- /dev/null +++ b/packages/crypto_keys_plus/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2018, Rik Bellens. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/crypto_keys_plus/README.md b/packages/crypto_keys_plus/README.md new file mode 100644 index 00000000..8ec0e91e --- /dev/null +++ b/packages/crypto_keys_plus/README.md @@ -0,0 +1,110 @@ + +[![Build Status](https://travis-ci.org/Bdaya-Dev/crypto_keys.svg?branch=master)](https://travis-ci.org/Bdaya-Dev/crypto_keys) +[:heart: sponsor](https://github.com/sponsors/rbellens) + +A library for doing cryptographic signing/verifying and encrypting/decrypting. + +It uses `pointycastle` under the hood, but exposes a more convenient +api. + + + +## Usage + +### Signing + +A simple usage example: + +```dart +import 'package:crypto_keys/crypto_keys.dart'; +import 'dart:typed_data'; + +main() { + // Create a key pair from a JWK representation + var keyPair = new KeyPair.fromJwk({ + "kty": "oct", + "k": "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75" + "aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" + }); + + // A key pair has a private and public key, possibly one of them is null, if + // required info was not available when construction + // The private key can be used for signing + var privateKey = keyPair.privateKey; + + // Create a signer for the key using the HMAC/SHA-256 algorithm + var signer = privateKey.createSigner(algorithms.signing.hmac.sha256); + + // Sign some content, to be integrity protected + var content = "It's me, really me"; + var signature = signer.sign("It's me, really me".codeUnits); + + print("Signing '$content'"); + print("Signature: ${signature.data}"); + + // The public key can be used for verifying the signature + var publicKey = keyPair.publicKey; + + // Create a verifier for the key using the specified algorithm + var verifier = publicKey.createVerifier(algorithms.signing.hmac.sha256); + + var verified = + verifier.verify(new Uint8List.fromList(content.codeUnits), signature); + if (verified) + print("Verification succeeded"); + else + print("Verification failed"); +} +``` + +### Encryption + +A simple usage example: + +```dart + +import 'package:crypto_keys/crypto_keys.dart'; +import 'dart:typed_data'; + +main() { + // Generate a new random symmetric key pair + var keyPair = new KeyPair.generateSymmetric(128); + + // Use the public key to create an encrypter with the AES/GCM algorithm + var encrypter = + keyPair.publicKey.createEncrypter(algorithms.encryption.aes.gcm); + + // Encrypt the content with an additional authentication data for integrity + // protection + var content = "A very secret text"; + var aad = "It is me"; + var v = encrypter.encrypt(new Uint8List.fromList(content.codeUnits), + additionalAuthenticatedData: new Uint8List.fromList(aad.codeUnits)); + + print("Encrypting '$content'"); + print("Ciphertext: ${v.data}"); + print("Authentication tag: ${v.authenticationTag}"); + + // Use the private key to create the decrypter + var decrypter = + keyPair.privateKey.createEncrypter(algorithms.encryption.aes.gcm); + + // Decrypt and verify authentication tag + var decrypted = decrypter.decrypt(v); + + print("Decrypted text: '${new String.fromCharCodes(decrypted)}'"); +} + +``` + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/Bdaya-Dev/crypto_keys/issues + + +## Sponsor + +Creating and maintaining this package takes a lot of time. If you like the result, please consider to [:heart: sponsor](https://github.com/sponsors/rbellens). +With your support, I will be able to further improve and support this project. diff --git a/packages/crypto_keys_plus/analysis_options.yaml b/packages/crypto_keys_plus/analysis_options.yaml new file mode 100644 index 00000000..00ddd243 --- /dev/null +++ b/packages/crypto_keys_plus/analysis_options.yaml @@ -0,0 +1,16 @@ +# Defines a default set of lint rules enforced for +# projects at Google. For details and rationale, +# see https://github.com/dart-lang/pedantic#enabled-lints. +include: package:lints/recommended.yaml + +# For lint rules and documentation, see http://dart-lang.github.io/linter/lints. +# Uncomment to specify additional rules. +linter: + rules: + - always_declare_return_types + - prefer_single_quotes + - unawaited_futures + +analyzer: +# exclude: +# - path/to/excluded/files/** diff --git a/packages/crypto_keys_plus/example/README.md b/packages/crypto_keys_plus/example/README.md new file mode 100644 index 00000000..2f5194b1 --- /dev/null +++ b/packages/crypto_keys_plus/example/README.md @@ -0,0 +1,7 @@ + +Run `pub run example/signing_example.dart` in the root of the package, for an example of crypto signing. + + +Run `pub run example/encryption_example.dart` in the root of the package, for an example of encryption. + + diff --git a/packages/crypto_keys_plus/example/encryption_example.dart b/packages/crypto_keys_plus/example/encryption_example.dart new file mode 100644 index 00000000..d276b762 --- /dev/null +++ b/packages/crypto_keys_plus/example/encryption_example.dart @@ -0,0 +1,31 @@ +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'dart:typed_data'; + +void main() { + // Generate a random symmetric key pair + var keyPair = KeyPair.generateSymmetric(128); + + // Use the public key to create an encrypter with the AES/GCM algorithm + var encrypter = + keyPair.publicKey!.createEncrypter(algorithms.encryption.aes.gcm); + + // Encrypt the content with an additional authentication data for integrity + // protection + var content = 'A very secret text'; + var aad = 'It is me'; + var v = encrypter.encrypt(Uint8List.fromList(content.codeUnits), + additionalAuthenticatedData: Uint8List.fromList(aad.codeUnits)); + + print("Encrypting '$content'"); + print('Ciphertext: ${v.data}'); + print('Authentication tag: ${v.authenticationTag}'); + + // Use the private key to create the decrypter + var decrypter = + keyPair.privateKey!.createEncrypter(algorithms.encryption.aes.gcm); + + // Decrypt and verify authentication tag + var decrypted = decrypter.decrypt(v); + + print("Decrypted text: '${String.fromCharCodes(decrypted)}'"); +} diff --git a/packages/crypto_keys_plus/example/signing_example.dart b/packages/crypto_keys_plus/example/signing_example.dart new file mode 100644 index 00000000..f5c8e085 --- /dev/null +++ b/packages/crypto_keys_plus/example/signing_example.dart @@ -0,0 +1,44 @@ +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'dart:typed_data'; + +void main() { + // Create a key pair from a JWK representation + var keyPair = KeyPair.fromJwk({ + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + }); + + if (keyPair == null) { + print('Unkown key type (kty)'); + return; + } + // A key pair has a private and public key, possibly one of them is null, if + // required info was not available when construction + // The private key can be used for signing + var privateKey = keyPair.privateKey!; + + // Create a signer for the key using the HMAC/SHA-256 algorithm + var signer = privateKey.createSigner(algorithms.signing.hmac.sha256); + + // Sign some content, to be integrity protected + var content = "It's me, really me"; + var signature = signer.sign("It's me, really me".codeUnits); + + print("Signing '$content'"); + print('Signature: ${signature.data}'); + + // The public key can be used for verifying the signature + var publicKey = keyPair.publicKey!; + + // Create a verifier for the key using the specified algorithm + var verifier = publicKey.createVerifier(algorithms.signing.hmac.sha256); + + var verified = + verifier.verify(Uint8List.fromList(content.codeUnits), signature); + if (verified) { + print('Verification succeeded'); + } else { + print('Verification failed'); + } +} diff --git a/packages/crypto_keys_plus/lib/crypto_keys.dart b/packages/crypto_keys_plus/lib/crypto_keys.dart new file mode 100644 index 00000000..d44d988d --- /dev/null +++ b/packages/crypto_keys_plus/lib/crypto_keys.dart @@ -0,0 +1,24 @@ +library crypto_keys; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; +import 'package:pointycastle/export.dart' as pc; + +import 'src/algorithms.dart'; +import 'src/impl.dart'; +import 'src/pointycastle_ext.dart' as pc; + +export 'src/algorithms.dart' + show algorithms, curves, Algorithms, AlgorithmIdentifier, Identifier; + +part 'src/asymmetric_operator.dart'; +part 'src/ec_keys.dart'; +part 'src/eddsa_operator.dart'; +part 'src/keys.dart'; +part 'src/okp_keys.dart'; +part 'src/operator.dart'; +part 'src/rsa_keys.dart'; +part 'src/symmetric_keys.dart'; +part 'src/symmetric_operator.dart'; diff --git a/packages/crypto_keys_plus/lib/crypto_keys_plus.dart b/packages/crypto_keys_plus/lib/crypto_keys_plus.dart new file mode 100644 index 00000000..03dd3fd0 --- /dev/null +++ b/packages/crypto_keys_plus/lib/crypto_keys_plus.dart @@ -0,0 +1,8 @@ +/// A library for doing cryptographic signing/verifying and +/// encrypting/decrypting. +/// +/// This is the package-name-matching entrypoint; the original +/// `crypto_keys.dart` entrypoint is kept for backwards compatibility. +library; + +export 'crypto_keys.dart'; diff --git a/packages/crypto_keys_plus/lib/src/algorithms.dart b/packages/crypto_keys_plus/lib/src/algorithms.dart new file mode 100644 index 00000000..c7e36820 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/algorithms.dart @@ -0,0 +1,486 @@ +library crypto_keys.algorithms; + +import 'dart:math' show Random; +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart' as pc; // TODO +import 'package:pointycastle/pointycastle.dart'; + +import 'pointycastle_ext.dart' as pce; + +/// Contains the identifiers for supported algorithms +/// +/// ## Encryption algorithms +/// +/// ### AES +/// +/// - [algorithms.encryption.aes.cbc] AES CBC +/// - [algorithms.encryption.aes.cbcWithHmac] AES CBC with HMAC +/// - [algorithms.encryption.aes.gcm] AES GCM +final algorithms = Algorithms(); + +class Algorithms { + /// Contains the identifiers for supported signing algorithms + final signing = _SigAlgorithms(); + + /// Contains the identifiers for supported encryption algorithms + final encryption = EncAlgorithms(); + + /// Contains the identifiers for supported digest algorithms + final digest = DigestAlgorithms(); + + @Deprecated('Use encryption.aes.cbc instead.') + // ignore: non_constant_identifier_names + AlgorithmIdentifier get encrypting_aes_cbc => encryption.aes.cbc; + + Algorithms(); +} + +class DigestAlgorithms extends Identifier { + DigestAlgorithms() : super._('digest'); + + /// SHA-1 digest + final sha1 = AlgorithmIdentifier._('digest/SHA-1', () => pc.SHA1Digest()); + + /// SHA-224 digest + final sha224 = + AlgorithmIdentifier._('digest/SHA-224', () => pc.SHA224Digest()); + + /// SHA-256 digest + final sha256 = + AlgorithmIdentifier._('digest/SHA-256', () => pc.SHA256Digest()); + + /// SHA-384 digest + final sha384 = + AlgorithmIdentifier._('digest/SHA-384', () => pc.SHA384Digest()); + + /// SHA-512 digest + final sha512 = + AlgorithmIdentifier._('digest/SHA-512', () => pc.SHA512Digest()); + + /// SHA-512/t digest + AlgorithmIdentifier sha512t(int digestSizeBytes) => AlgorithmIdentifier._( + 'digest/SHA-512/${digestSizeBytes * 8}', + () => pc.SHA512tDigest(digestSizeBytes)); + + /// MD2 digest + final md2 = AlgorithmIdentifier._('digest/MD2', () => pc.MD2Digest()); + + /// MD4 digest + final md4 = AlgorithmIdentifier._('digest/MD4', () => pc.MD4Digest()); + + /// MD5 digest + final md5 = AlgorithmIdentifier._('digest/MD5', () => pc.MD5Digest()); +} + +class EncAlgorithms extends Identifier { + /// Contains the identifiers for supported AES encryption algorithms + final aes = AesEncAlgorithms(); + + /// Contains the identifiers for supported RSA encryption algorithms + final rsa = _RsaEncAlgorithms(); + + /// Contains the identifiers for supported hybrid encryption algorithms + final hybrid = HybridEncAlgorithms(); + + EncAlgorithms() : super._('enc'); +} + +class HybridEncAlgorithms extends Identifier { + HybridEncAlgorithms() : super._('enc/hybrid'); + + AlgorithmIdentifier withParameters( + {required keySize, + required Identifier curve, + required AlgorithmIdentifier hkdfHash}) { + return AlgorithmIdentifier._( + 'enc/hybrid', () => pc.HKDFKeyDerivator(hkdfHash.factory())); + } +} + +class AesEncAlgorithms extends Identifier { + /// AES CBC + final cbc = AlgorithmIdentifier._( + 'enc/AES/CBC/PKCS7', + () => pc.PaddedBlockCipherImpl( + pc.PKCS7Padding(), pc.CBCBlockCipher(pc.AESEngine()))); + + final cbcWithHmac = AesWithHmacEncAlgorithms(); + + /// AES GCM + + final gcm = AlgorithmIdentifier._( + 'enc/AES/GCM', () => pc.GCMBlockCipher(pc.AESEngine())); + + /// AES EAX + final eax = + AlgorithmIdentifier._('enc/AES/EAX', () => throw UnimplementedError()); + + /// AES Key Wrap with default initial value + final keyWrap = AlgorithmIdentifier._('enc/AES/KW', () => pce.AESKeyWrap()); + + AesEncAlgorithms() : super._('enc/AES'); +} + +class AesWithHmacEncAlgorithms extends Identifier { + /// AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm + final sha256 = AlgorithmIdentifier._( + 'enc/AES/CBC/PKCS7+HMAC/SHA-256', + () => pce.AesCbcAuthenticatedCipherWithHash( + algorithms.signing.hmac.sha256.createAlgorithm())); + + /// AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm + final sha384 = AlgorithmIdentifier._( + 'enc/AES/CBC/PKCS7+HMAC/SHA-384', + () => pce.AesCbcAuthenticatedCipherWithHash( + algorithms.signing.hmac.sha384.createAlgorithm())); + + /// AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm + final sha512 = AlgorithmIdentifier._( + 'enc/AES/CBC/PKCS7+HMAC/SHA-512', + () => pce.AesCbcAuthenticatedCipherWithHash( + algorithms.signing.hmac.sha512.createAlgorithm())); + + AesWithHmacEncAlgorithms() : super._('enc/AES/CBC/PKCS7+HMAC'); +} + +class _RsaEncAlgorithms extends Identifier { + /// RSAES-PKCS1-v1_5 + final pkcs1 = AlgorithmIdentifier._( + 'enc/RSA/PKCS1', () => pc.PKCS1Encoding(pc.RSAEngine())); + + /// RSAES OAEP using default parameters + final oaep = AlgorithmIdentifier._('enc/RSA/ECB/OAEPWithSHA-1AndMGF1Padding', + () => pc.OAEPEncoding.withSHA1(pc.RSAEngine())); + + /// RSAES OAEP using SHA-256 and MGF1 with SHA-256 + final oaep256 = AlgorithmIdentifier._( + 'enc/RSA/ECB/OAEPWithSHA-256AndMGF1Padding', + () => pc.OAEPEncoding.withSHA256(pc.RSAEngine())); + + _RsaEncAlgorithms() : super._('enc/RSA'); +} + +class _SigAlgorithms extends Identifier { + /// Contains the identifiers for supported HMAC signing algorithms + final hmac = _HmacSigAlgorithms(); + + /// Contains the identifiers for supported RSA signing algorithms + final rsa = _RsaSigAlgorithms(); + + /// Contains the identifiers for supported ECDSA signing algorithms + final ecdsa = _EcdsaSigAlgorithms(); + + /// Contains the identifiers for supported EdDSA signing algorithms + final eddsa = _EddsaSigAlgorithms(); + + _SigAlgorithms() : super._('sig'); +} + +class _HmacSigAlgorithms extends Identifier { + /// HMAC using SHA-256 + final sha256 = AlgorithmIdentifier._( + 'sig/HMAC/SHA-256', () => pc.HMac(pc.SHA256Digest(), 64)); + + /// HMAC using SHA-384 + final sha384 = AlgorithmIdentifier._( + 'sig/HMAC/SHA-384', () => pc.HMac(pc.SHA384Digest(), 128)); + + /// HMAC using SHA-512 + final sha512 = AlgorithmIdentifier._( + 'sig/HMAC/SHA-512', () => pc.HMac(pc.SHA512Digest(), 128)); + + _HmacSigAlgorithms() : super._('sig/HMAC'); +} + +class _RsaSigAlgorithms extends Identifier { + /// RSASSA-PKCS1-v1_5 using SHA-256 + final sha256 = AlgorithmIdentifier._('sig/RSA/SHA-256', + () => pc.RSASigner(pc.SHA256Digest(), '0609608648016503040201')); + + /// RSASSA-PKCS1-v1_5 using SHA-384 + final sha384 = AlgorithmIdentifier._('sig/RSA/SHA-384', + () => pc.RSASigner(pc.SHA384Digest(), '0609608648016503040202')); + + /// RSASSA-PKCS1-v1_5 using SHA-512 + final sha512 = AlgorithmIdentifier._('sig/RSA/SHA-512', + () => pc.RSASigner(pc.SHA512Digest(), '0609608648016503040203')); + + /// Contains the identifiers for supported RSASSA-PSS signing algorithms + final pss = _RsaPssAlgorithms(); + + _RsaSigAlgorithms() : super._('sig/RSA'); +} + +class _RsaPssAlgorithms extends Identifier { + _RsaPssAlgorithms() : super._('sig/RSA/PSS'); + + /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 (salt length 32). + final sha256 = AlgorithmIdentifier._('sig/RSA/PSS/SHA-256', + () => pc.PSSSigner(pc.RSAEngine(), pc.SHA256Digest(), pc.SHA256Digest())); + + /// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 (salt length 48). + final sha384 = AlgorithmIdentifier._('sig/RSA/PSS/SHA-384', + () => pc.PSSSigner(pc.RSAEngine(), pc.SHA384Digest(), pc.SHA384Digest())); + + /// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 (salt length 64). + final sha512 = AlgorithmIdentifier._('sig/RSA/PSS/SHA-512', + () => pc.PSSSigner(pc.RSAEngine(), pc.SHA512Digest(), pc.SHA512Digest())); + + /// RSASSA-PSS with an explicit content digest, MGF1 digest and salt length. + /// + /// The chosen [saltLength] is encoded into the resulting identifier name (as + /// a `/salt` suffix) so the signer/verifier can recover it. + AlgorithmIdentifier withParameters( + {required AlgorithmIdentifier sigHash, + required AlgorithmIdentifier mgf1Hash, + required int saltLength}) { + return AlgorithmIdentifier._( + 'sig/RSA/PSS/${sigHash.name}/mgf1${mgf1Hash.name}/salt$saltLength', + () => pc.PSSSigner(pc.RSAEngine(), sigHash.factory() as pc.Digest, + mgf1Hash.factory() as pc.Digest)); + } +} + +/// Placeholder pointycastle [pc.Algorithm] for EdDSA. +/// +/// pointycastle has no Ed25519 implementation. The EdDSA signer/verifier +/// (see `eddsa_operator.dart`) bypass pointycastle entirely and never use this +/// instance; it exists only so the generic [Operator] constructor (which eagerly +/// builds a [pc.Algorithm]) has something to hold. +class _Ed25519Algorithm implements pc.Algorithm { + @override + String get algorithmName => 'Ed25519'; +} + +class _EddsaSigAlgorithms extends Identifier { + _EddsaSigAlgorithms() : super._('sig/EdDSA'); + + /// EdDSA using the Ed25519 curve (RFC 8037). + final ed25519 = AlgorithmIdentifier._( + 'sig/EdDSA/Ed25519', () => _Ed25519Algorithm()); +} + +class _EcdsaSigAlgorithms extends Identifier { + /// ECDSA using P-256 and SHA-256 + final sha256 = AlgorithmIdentifier._( + 'sig/ECDSA/SHA-256', () => pc.ECDSASigner(pc.SHA256Digest(), null)); + + /// ECDSA using P-384 and SHA-384 + final sha384 = AlgorithmIdentifier._( + 'sig/ECDSA/SHA-384', () => pc.ECDSASigner(pc.SHA384Digest(), null)); + + /// ECDSA using P-521 and SHA-512 + final sha512 = AlgorithmIdentifier._( + 'sig/ECDSA/SHA-512', () => pc.ECDSASigner(pc.SHA512Digest(), null)); + + _EcdsaSigAlgorithms() : super._('sig/ECDSA'); +} + +/// Contains the identifiers for supported cryptographic curves +final curves = _Curves(); + +class _Curves { + /// P-256 + final p256 = const Identifier._('curve/P-256'); + + /// P-384 + final p384 = const Identifier._('curve/P-384'); + + /// P-521 + final p521 = const Identifier._('curve/P-521'); + + /// P-256K + final p256k = const Identifier._('curve/P-256K'); + + /// Ed25519 (RFC 8037 / RFC 8032) + final ed25519 = const Identifier._('curve/Ed25519'); +} + +/// An identifier for uniquely identify algorithms and other objects +class Identifier { + final String name; + + const Identifier._(this.name); + + @override + int get hashCode => name.hashCode; + + @override + bool operator ==(other) => other is Identifier && other.name == name; +} + +class AlgorithmIdentifier extends Identifier { + final T Function() factory; + + AlgorithmIdentifier._(String name, this.factory) : super._(name); + + static final _jwaAlgorithms = { + // Algorithms for JWS + + /// HMAC using SHA-256 + 'HS256': algorithms.signing.hmac.sha256, + + /// HMAC using SHA-384 + 'HS384': algorithms.signing.hmac.sha384, + + /// HMAC using SHA-512 + 'HS512': algorithms.signing.hmac.sha512, + + /// RSASSA-PKCS1-v1_5 using SHA-256 + 'RS256': algorithms.signing.rsa.sha256, + + /// RSASSA-PKCS1-v1_5 using SHA-384 + 'RS384': algorithms.signing.rsa.sha384, + + /// RSASSA-PKCS1-v1_5 using SHA-512 + 'RS512': algorithms.signing.rsa.sha512, + + /// ECDSA using P-256 and SHA-256 + 'ES256': algorithms.signing.ecdsa.sha256, + + /// ECDSA using P-256K and SHA-256 + 'ES256K': algorithms.signing.ecdsa.sha256, + + /// ECDSA using P-384 and SHA-384 + 'ES384': algorithms.signing.ecdsa.sha384, + + /// ECDSA using P-521 and SHA-512 + 'ES512': algorithms.signing.ecdsa.sha512, + + /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + 'PS256': algorithms.signing.rsa.pss.sha256, + + /// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 + 'PS384': algorithms.signing.rsa.pss.sha384, + + /// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 + 'PS512': algorithms.signing.rsa.pss.sha512, + + /// EdDSA using Ed25519 (RFC 8037) + 'EdDSA': algorithms.signing.eddsa.ed25519, + + /// The fully-specified Ed25519 algorithm identifier, treated as `EdDSA`. + 'Ed25519': algorithms.signing.eddsa.ed25519, + + /// No digital signature or MAC + 'none': null, + + // Algorithms for JWE + /// RSAES-PKCS1-v1_5 + 'RSA1_5': algorithms.encryption.rsa.pkcs1, + + /// RSAES OAEP using default parameters + 'RSA-OAEP': algorithms.encryption.rsa.oaep, + + /// RSAES OAEP using SHA-256 and MGF1 with SHA-256 + 'RSA-OAEP-256': algorithms.encryption.rsa.oaep256, + + /// AES Key Wrap with default initial value using 128-bit key + 'A128KW': algorithms.encryption.aes.keyWrap, + + /// AES Key Wrap with default initial value using 192-bit key + 'A192KW': algorithms.encryption.aes.keyWrap, + + /// AES Key Wrap with default initial value using 256-bit key + 'A256KW': algorithms.encryption.aes.keyWrap, + + /// Direct use of a shared symmetric key as the CEK + 'dir': null, + + /// Elliptic Curve Diffie-Hellman Ephemeral Static key agreement using Concat KDF + 'ECDH-ES': null, + + /// ECDH-ES using Concat KDF and CEK wrapped with 'A128KW' + 'ECDH-ES+A128KW': null, + + /// ECDH-ES using Concat KDF and CEK wrapped with 'A192KW' + 'ECDH-ES+A192KW': null, + + /// ECDH-ES using Concat KDF and CEK wrapped with 'A256KW' + 'ECDH-ES+A256KW': null, + + /// Key wrapping with AES GCM using 128-bit key + 'A128GCMKW': null, + + /// Key wrapping with AES GCM using 192-bit key + 'A192GCMKW': null, + + /// Key wrapping with AES GCM using 256-bit key + 'A256GCMKW': null, + + /// PBES2 with HMAC SHA-256 and 'A128KW' wrapping + 'PBES2-HS256+A128KW': null, + + /// PBES2 with HMAC SHA-384 and 'A192KW' wrapping + 'PBES2-HS384+A192KW': null, + + /// PBES2 with HMAC SHA-512 and 'A256KW' wrapping + 'PBES2-HS512+A256KW': null, + + // Encryption Algorithms for JWE + /// AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm + 'A128CBC-HS256': algorithms.encryption.aes.cbcWithHmac.sha256, + + /// AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm + 'A192CBC-HS384': algorithms.encryption.aes.cbcWithHmac.sha384, + + /// AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm + 'A256CBC-HS512': algorithms.encryption.aes.cbcWithHmac.sha512, + + /// AES GCM using 128-bit key + 'A128GCM': algorithms.encryption.aes.gcm, + + /// AES GCM using 192-bit key + 'A192GCM': algorithms.encryption.aes.gcm, + + /// AES GCM using 256-bit key + 'A256GCM': algorithms.encryption.aes.gcm, + }; + + static AlgorithmIdentifier? getByJwaName(String alg) { + var i = _jwaAlgorithms[alg]; + if (i == null && alg != 'none') { + if (_jwaAlgorithms.containsKey(alg)) { + throw UnimplementedError("Algorithm '$alg' not implemented"); + } + throw UnsupportedError("Algorithm '$alg' not supported"); + } + return i; + } + + T createAlgorithm() => factory(); +} + +class DefaultSecureRandom implements pc.SecureRandom { + final Random random = Random.secure(); + + @override + String get algorithmName => 'dart.math.Random.secure()'; + + @override + BigInt nextBigInteger(int bitLength) { + return BigInt.parse( + Iterable.generate(bitLength, (_) => random.nextBool() ? '1' : '0') + .join(''), + radix: 2); + } + + @override + Uint8List nextBytes(int count) => + Uint8List.fromList(List.generate(count, (_) => nextUint8())); + + @override + int nextUint16() => random.nextInt(256 * 256); + + @override + int nextUint32() => random.nextInt(256 * 256 * 256 * 256); + + @override + int nextUint8() => random.nextInt(256); + + @override + void seed(pc.CipherParameters params) { + throw UnsupportedError('Seed not supported for this SecureRandom'); + } +} diff --git a/packages/crypto_keys_plus/lib/src/asymmetric_operator.dart b/packages/crypto_keys_plus/lib/src/asymmetric_operator.dart new file mode 100644 index 00000000..ed6defb1 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/asymmetric_operator.dart @@ -0,0 +1,229 @@ +part of '../crypto_keys.dart'; + +abstract mixin class _AsymmetricOperator implements Operator { + static pc.ECDomainParameters createCurveParameters(Identifier curve) { + var name = curve.name.split('/').last; + switch (name) { + case 'P-256': + return pc.ECCurve_secp256r1(); + case 'P-256K': + return pc.ECCurve_secp256k1(); + case 'P-384': + return pc.ECCurve_secp384r1(); + case 'P-521': + return pc.ECCurve_secp521r1(); + } + throw ArgumentError('Unknwon curve type $name'); + } + + pc.ECDomainParameters get ecDomainParameters => + createCurveParameters((key as EcKey).curve); + + pc.AsymmetricKeyParameter get keyParameter { + if (key is RsaPrivateKey) { + var k = key as RsaPrivateKey; + return pc.PrivateKeyParameter(pc.RSAPrivateKey( + k.modulus, + k.privateExponent, + k.firstPrimeFactor, + k.secondPrimeFactor)); + } + if (key is RsaPublicKey) { + var k = key as RsaPublicKey; + return pc.PublicKeyParameter(pc.RSAPublicKey( + k.modulus, + k.exponent, + )); + } + var d = ecDomainParameters; + + if (key is EcPrivateKey) { + var k = key as EcPrivateKey; + return pc.PrivateKeyParameter(pc.ECPrivateKey( + k.eccPrivateKey, + d, + )); + } + if (key is EcPublicKey) { + var k = key as EcPublicKey; + + return pc.PublicKeyParameter( + pc.ECPublicKey(d.curve.createPoint(k.xCoordinate, k.yCoordinate), d)); + } + throw StateError('Unexpected key type $key'); + } +} + +class _AsymmetricSigner extends Signer + with _AsymmetricOperator { + _AsymmetricSigner(Identifier algorithm, PrivateKey key) + : super._(algorithm, key); + + @override + pc.Signer get _algorithm => super._algorithm as pc.Signer; + + @override + Signature sign(List data) { + data = data is Uint8List ? data : Uint8List.fromList(data); + + // RSASSA-PSS MUST be handled before the generic `key is RsaKey` branch: a + // PSS key IS an RsaKey, but [pc.PSSSigner] needs a + // [pc.ParametersWithSaltConfiguration] (not [pc.ParametersWithRandom]) and + // produces a [pc.PSSSignature] (not a [pc.RSASignature]). + if (_algorithm is pc.PSSSigner) { + final saltLength = _pssSaltLength(algorithm.name); + _algorithm.init( + true, + pc.ParametersWithSaltConfiguration( + keyParameter, DefaultSecureRandom(), saltLength)); + return Signature( + (_algorithm.generateSignature(data) as pc.PSSSignature).bytes); + } + + _algorithm.init( + true, pc.ParametersWithRandom(keyParameter, DefaultSecureRandom())); + + if (key is RsaKey) { + return Signature( + (_algorithm.generateSignature(data) as pc.RSASignature).bytes); + } + if (key is EcKey) { + var sig = _algorithm.generateSignature(data) as pc.ECSignature; + + var length = { + curves.p256: 32, + curves.p256k: 32, + curves.p384: 48, + curves.p521: 66 + }[(key as EcKey).curve]!; + var bytes = Uint8List(length * 2); + bytes.setRange( + 0, length, _bigIntToBytes(sig.r, length).toList().reversed); + bytes.setRange( + length, length * 2, _bigIntToBytes(sig.s, length).toList().reversed); + + return Signature(bytes); + } + throw UnsupportedError('Unknown key type $key'); + } +} + +class _AsymmetricVerifier extends Verifier + with _AsymmetricOperator { + _AsymmetricVerifier(Identifier algorithm, PublicKey key) + : super._(algorithm, key); + + @override + pc.Signer get _algorithm => super._algorithm as pc.Signer; + + @override + bool verify(Uint8List data, Signature signature) { + // RSASSA-PSS MUST be handled before the generic `key is RsaKey` branch: a + // PSS key IS an RsaKey, but [pc.PSSSigner.verifySignature] takes a + // [pc.PSSSignature] and [pc.PSSSigner.init] only accepts a + // [pc.ParametersWithSaltConfiguration] / [pc.ParametersWithSalt]. + if (_algorithm is pc.PSSSigner) { + final saltLength = _pssSaltLength(algorithm.name); + try { + _algorithm.init( + false, + pc.ParametersWithSaltConfiguration( + keyParameter, DefaultSecureRandom(), saltLength)); + return (_algorithm as pc.PSSSigner) + .verifySignature(data, pc.PSSSignature(signature.data)); + } catch (_) { + // verify() must return false on ANY failure (bad signature, key too + // small for the requested hash+salt, malformed PSSSignature), never + // throw — mirroring the EdDSA verifier's fail-closed contract. + return false; + } + } + if (key is RsaKey) { + _algorithm.init(false, + pc.ParametersWithRandom(keyParameter, pc.SecureRandom('Fortuna'))); + try { + return _algorithm.verifySignature( + data, pc.RSASignature(signature.data)); + } on ArgumentError { + return false; + } + } + if (key is EcKey) { + _algorithm.init(false, keyParameter); + + var l = signature.data.length ~/ 2; + + return _algorithm.verifySignature( + data, + pc.ECSignature( + _bigIntFromBytes(signature.data.take(l)), + _bigIntFromBytes(signature.data.skip(l)), + )); + } + throw UnsupportedError('Unknown key type $key'); + } +} + +class _AsymmetricEncrypter extends Encrypter with _AsymmetricOperator { + _AsymmetricEncrypter(Identifier algorithm, Key key) : super._(algorithm, key); + + @override + pc.AsymmetricBlockCipher get _algorithm => + super._algorithm as pc.AsymmetricBlockCipher; + + @override + Uint8List decrypt(EncryptionResult input) { + _algorithm.init( + false, + pc.ParametersWithRandom(keyParameter, pc.SecureRandom('Fortuna') + // ..seed(pc.KeyParameter(Uint8List(32))) + )); + + return _algorithm.process(input.data); + } + + @override + EncryptionResult encrypt(List input, + {Uint8List? initializationVector, + Uint8List? additionalAuthenticatedData}) { + _algorithm.init( + true, pc.ParametersWithRandom(keyParameter, DefaultSecureRandom())); + + return EncryptionResult(_algorithm.process(input as Uint8List)); + } +} + +final _b256 = BigInt.from(256); + +Iterable _bigIntToBytes(BigInt v, int length) sync* { + for (var i = 0; i < length; i++) { + yield (v % _b256).toInt(); + v = v ~/ _b256; + } +} + +BigInt _bigIntFromBytes(Iterable bytes) { + return bytes.fold(BigInt.zero, (a, b) => a * _b256 + BigInt.from(b)); +} + +/// Salt length for RSASSA-PSS. +/// +/// For the JWA PS256/384/512 identifiers the salt length equals the hash output +/// size (RFC 7518 §3.5). Identifiers built via +/// `algorithms.signing.rsa.pss.withParameters` carry an explicit `/salt` +/// suffix instead. +int _pssSaltLength(String algorithmName) { + switch (algorithmName) { + case 'sig/RSA/PSS/SHA-256': + return 32; + case 'sig/RSA/PSS/SHA-384': + return 48; + case 'sig/RSA/PSS/SHA-512': + return 64; + } + final match = RegExp(r'/salt(\d+)$').firstMatch(algorithmName); + if (match != null) { + return int.parse(match.group(1)!); + } + throw ArgumentError('Unknown RSASSA-PSS algorithm: $algorithmName'); +} diff --git a/packages/crypto_keys_plus/lib/src/ec_keys.dart b/packages/crypto_keys_plus/lib/src/ec_keys.dart new file mode 100644 index 00000000..35780e37 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/ec_keys.dart @@ -0,0 +1,31 @@ +part of '../crypto_keys.dart'; + +/// Base class for elliptic curve (EC) keys +abstract class EcKey extends Key { + /// The cryptographic curve used with the key + Identifier get curve; +} + +/// An elliptic curve (EC) public key +abstract class EcPublicKey extends EcKey implements PublicKey { + /// The x coordinate for the Elliptic Curve point + BigInt get xCoordinate; + + /// The y coordinate for the Elliptic Curve point + BigInt get yCoordinate; + + factory EcPublicKey( + {required BigInt xCoordinate, + required BigInt yCoordinate, + required Identifier curve}) = EcPublicKeyImpl; +} + +/// An elliptic curve (EC) private key +abstract class EcPrivateKey extends EcKey implements PrivateKey { + /// The Elliptic Curve private key value + BigInt get eccPrivateKey; + + factory EcPrivateKey( + {required BigInt eccPrivateKey, + required Identifier curve}) = EcPrivateKeyImpl; +} diff --git a/packages/crypto_keys_plus/lib/src/eddsa_operator.dart b/packages/crypto_keys_plus/lib/src/eddsa_operator.dart new file mode 100644 index 00000000..f1ab5285 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/eddsa_operator.dart @@ -0,0 +1,50 @@ +part of '../crypto_keys.dart'; + +void _assertEd25519(Identifier curve) { + if (curve != curves.ed25519) { + throw UnsupportedError( + 'Only the Ed25519 OKP curve is supported, got `${curve.name}`.'); + } +} + +/// Signer for EdDSA (RFC 8037) using Ed25519. +/// +/// pointycastle has no Ed25519 implementation, so this signer bypasses the +/// generic [_AsymmetricOperator] / pointycastle dispatch and uses +/// `package:ed25519_edwards` directly. +class _EddsaSigner extends Signer { + _EddsaSigner(Identifier algorithm, PrivateKey key) : super._(algorithm, key); + + @override + Signature sign(List data) { + final okp = key as OkpPrivateKey; + _assertEd25519(okp.curve); + // RFC 8037: `d` is the RFC 8032 seed; expand it to the full private key. + final priv = ed.newKeyFromSeed(Uint8List.fromList(okp.rawBytes)); + final message = data is Uint8List ? data : Uint8List.fromList(data); + // PureEdDSA over the full message; no pre-hashing (RFC 8032 hashes + // internally). + return Signature(ed.sign(priv, message)); + } +} + +/// Verifier for EdDSA (RFC 8037) using Ed25519. +class _EddsaVerifier extends Verifier { + _EddsaVerifier(Identifier algorithm, PublicKey key) : super._(algorithm, key); + + @override + bool verify(Uint8List data, Signature signature) { + final okp = key as OkpPublicKey; + _assertEd25519(okp.curve); + try { + return ed.verify( + ed.PublicKey(okp.rawBytes), + data, + signature.data, + ); + } catch (_) { + // e.g. ArgumentError on bad key/signature length -> not valid. + return false; + } + } +} diff --git a/packages/crypto_keys_plus/lib/src/impl.dart b/packages/crypto_keys_plus/lib/src/impl.dart new file mode 100644 index 00000000..0cfa620c --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/impl.dart @@ -0,0 +1,222 @@ +import '../crypto_keys.dart'; + +import 'dart:typed_data'; +import 'package:collection/collection.dart'; + +class RsaPublicKeyImpl extends PublicKey + with Key + implements RsaPublicKey, RsaKey { + @override + final BigInt exponent; + @override + final BigInt modulus; + + RsaPublicKeyImpl({required this.modulus, required this.exponent}); + + @override + int get hashCode => Object.hash(exponent, modulus); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RsaPublicKey && + other.exponent == exponent && + other.modulus == modulus); +} + +class RsaPrivateKeyImpl extends PrivateKey + with Key + implements RsaPrivateKey, RsaKey { + @override + final BigInt firstPrimeFactor; + + @override + final BigInt modulus; + + @override + final BigInt privateExponent; + + @override + final BigInt secondPrimeFactor; + + RsaPrivateKeyImpl( + {required this.privateExponent, + required this.firstPrimeFactor, + required this.secondPrimeFactor, + required this.modulus}); + + @override + int get hashCode => Object.hash( + privateExponent, firstPrimeFactor, secondPrimeFactor, modulus); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RsaPrivateKey && + other.privateExponent == privateExponent && + other.firstPrimeFactor == firstPrimeFactor && + other.secondPrimeFactor == secondPrimeFactor && + other.modulus == modulus); +} + +class EcPublicKeyImpl extends PublicKey with Key implements EcPublicKey, EcKey { + @override + final Identifier curve; + + @override + final BigInt xCoordinate; + + @override + final BigInt yCoordinate; + + EcPublicKeyImpl( + {required this.xCoordinate, + required this.yCoordinate, + required this.curve}); + + @override + int get hashCode => Object.hash(xCoordinate, yCoordinate, curve); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is EcPublicKey && + other.xCoordinate == xCoordinate && + other.yCoordinate == yCoordinate && + other.curve == curve); +} + +class EcPrivateKeyImpl extends PrivateKey + with Key + implements EcPrivateKey, EcKey { + @override + final Identifier curve; + + @override + final BigInt eccPrivateKey; + + EcPrivateKeyImpl({required this.eccPrivateKey, required this.curve}); + + @override + int get hashCode => Object.hash(eccPrivateKey, curve); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is EcPrivateKey && + other.eccPrivateKey == eccPrivateKey && + other.curve == curve); +} + +class OkpPublicKeyImpl extends PublicKey + with Key + implements OkpPublicKey, OkpKey { + @override + final Uint8List rawBytes; + + @override + final Identifier curve; + + OkpPublicKeyImpl({required this.rawBytes, required this.curve}); + + @override + int get hashCode => Object.hash(const ListEquality().hash(rawBytes), curve); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OkpPublicKey && + other.curve == curve && + const ListEquality().equals(other.rawBytes, rawBytes)); +} + +class OkpPrivateKeyImpl extends PrivateKey + with Key + implements OkpPrivateKey, OkpKey { + @override + final Uint8List rawBytes; + + @override + final Identifier curve; + + OkpPrivateKeyImpl({required this.rawBytes, required this.curve}); + + @override + int get hashCode => Object.hash(const ListEquality().hash(rawBytes), curve); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is OkpPrivateKey && + other.curve == curve && + const ListEquality().equals(other.rawBytes, rawBytes)); +} + +class SymmetricKeyImpl extends Object + with Key, PublicKey, PrivateKey + implements SymmetricKey { + @override + final Uint8List keyValue; + + SymmetricKeyImpl({required this.keyValue}); + + @override + int get hashCode => const ListEquality().hash(keyValue); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SymmetricKey && + const ListEquality().equals(other.keyValue, keyValue)); +} + +class SignatureImpl implements Signature { + @override + final Uint8List data; + + SignatureImpl(this.data); + + @override + int get hashCode => const ListEquality().hash(data); + + @override + bool operator ==(other) => + other is Signature && const ListEquality().equals(other.data, data); +} + +class EncryptionResultImpl implements EncryptionResult { + @override + final Uint8List data; + + @override + final Uint8List? initializationVector; + + @override + final Uint8List? authenticationTag; + + @override + final Uint8List? additionalAuthenticatedData; + + EncryptionResultImpl(this.data, + {this.initializationVector, + this.authenticationTag, + this.additionalAuthenticatedData}); + + @override + int get hashCode => Object.hash( + const ListEquality().hash(data), + const ListEquality().hash(initializationVector), + const ListEquality().hash(authenticationTag), + const ListEquality().hash(additionalAuthenticatedData), + ); + + @override + bool operator ==(other) => + other is EncryptionResult && + const ListEquality().equals(other.data, data) && + const ListEquality() + .equals(other.initializationVector, initializationVector) && + const ListEquality().equals(other.authenticationTag, authenticationTag) && + const ListEquality().equals( + other.additionalAuthenticatedData, additionalAuthenticatedData); +} diff --git a/packages/crypto_keys_plus/lib/src/keys.dart b/packages/crypto_keys_plus/lib/src/keys.dart new file mode 100644 index 00000000..319f6b64 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/keys.dart @@ -0,0 +1,235 @@ +part of '../crypto_keys.dart'; + +/// A cryptographic key +abstract mixin class Key { + /// Creates an [Encrypter] using this key and the specified algorithm + Encrypter createEncrypter(Identifier algorithm) { + if (this is SymmetricKey) { + return _SymmetricEncrypter(algorithm, this as SymmetricKey); + } + + return _AsymmetricEncrypter(algorithm, this); + } +} + +/// A cryptographic public key +abstract mixin class PublicKey implements Key { + /// Creates a signature [Verifier] using this key and the specified algorithm + Verifier createVerifier(Identifier algorithm) { + if (this is SymmetricKey) { + return _SymmetricSignerAndVerifier(algorithm, this as SymmetricKey); + } + if (this is OkpPublicKey) { + // EdDSA bypasses pointycastle (no Ed25519 support there). + return _EddsaVerifier(algorithm, this); + } + + return _AsymmetricVerifier(algorithm, this); + } +} + +/// A cryptographic private key +abstract mixin class PrivateKey implements Key { + /// Creates a [Signer] using this key and the specified algorithm. + Signer createSigner(Identifier algorithm) { + if (this is SymmetricKey) { + return _SymmetricSignerAndVerifier(algorithm, this as SymmetricKey); + } + if (this is OkpPrivateKey) { + // EdDSA bypasses pointycastle (no Ed25519 support there). + return _EddsaSigner(algorithm, this); + } + + return _AsymmetricSigner(algorithm, this); + } +} + +/// Holds a key pair (private and public key) +class KeyPair { + /// The public key + final PublicKey? publicKey; + + /// The private key + final PrivateKey? privateKey; + + /// Creates a [KeyPair] from a public and private key + KeyPair({required this.publicKey, required this.privateKey}); + + /// Creates a [KeyPair] from a symmetric key + KeyPair.symmetric(SymmetricKey key) : this(privateKey: key, publicKey: key); + + /// Generates a random symmetric [KeyPair] with specified bit length + factory KeyPair.generateSymmetric(int bitLength) => + KeyPair.symmetric(SymmetricKey.generate(bitLength)); + + factory KeyPair.generateRsa({BigInt? exponent, int bitStrength = 2048}) { + exponent ??= BigInt.from(65537); + + var generator = pc.RSAKeyGenerator() + ..init(pc.ParametersWithRandom( + pc.RSAKeyGeneratorParameters(exponent, bitStrength, 5), + DefaultSecureRandom())); + + var pair = generator.generateKeyPair(); + + return KeyPair( + publicKey: RsaPublicKey( + exponent: pair.publicKey.publicExponent!, + modulus: pair.publicKey.n!, + ), + privateKey: RsaPrivateKey( + modulus: pair.privateKey.n!, + privateExponent: pair.privateKey.privateExponent!, + firstPrimeFactor: pair.privateKey.p!, + secondPrimeFactor: pair.privateKey.q!, + )); + } + + factory KeyPair.generateEc(Identifier curve) { + var generator = pc.ECKeyGenerator() + ..init( + pc.ParametersWithRandom( + pc.ECKeyGeneratorParameters( + _AsymmetricOperator.createCurveParameters(curve), + ), + DefaultSecureRandom(), + ), + ); + + var pair = generator.generateKeyPair(); + + return KeyPair( + publicKey: EcPublicKey( + xCoordinate: pair.publicKey.Q!.x!.toBigInteger()!, + yCoordinate: pair.publicKey.Q!.y!.toBigInteger()!, + curve: curve), + privateKey: + EcPrivateKey(eccPrivateKey: pair.privateKey.d!, curve: curve)); + } + + /// Generates a random Ed25519 (OKP) [KeyPair] (RFC 8037). + factory KeyPair.generateEd25519() { + final pair = ed.generateKey(); + return KeyPair( + publicKey: OkpPublicKey( + rawBytes: Uint8List.fromList(pair.publicKey.bytes), + curve: curves.ed25519, + ), + privateKey: OkpPrivateKey( + // RFC 8037 `d` is the RFC 8032 seed, not the expanded private key. + rawBytes: ed.seed(pair.privateKey), + curve: curves.ed25519, + ), + ); + } + + /// Create a key pair from a JsonWebKey + static KeyPair? fromJwk(Map jwk) { + switch (jwk['kty']) { + case 'oct': + var key = SymmetricKey(keyValue: _base64ToBytes(jwk['k']) as Uint8List); + return KeyPair(publicKey: key, privateKey: key); + case 'RSA': + return KeyPair( + publicKey: jwk.containsKey('n') && jwk.containsKey('e') + ? RsaPublicKey( + modulus: _base64ToInt(jwk['n']), + exponent: _base64ToInt(jwk['e']), + ) + : null, + privateKey: jwk.containsKey('n') && + jwk.containsKey('d') && + jwk.containsKey('p') && + jwk.containsKey('q') + ? RsaPrivateKey( + modulus: _base64ToInt(jwk['n']), + privateExponent: _base64ToInt(jwk['d']), + firstPrimeFactor: _base64ToInt(jwk['p']), + secondPrimeFactor: _base64ToInt(jwk['q']), + ) + : null); + case 'EC': + final curve = _parseCurve(jwk['crv']); + return KeyPair( + privateKey: jwk.containsKey('d') && curve != null + ? EcPrivateKey( + eccPrivateKey: _base64ToInt(jwk['d']), + curve: curve, + ) + : null, + publicKey: + jwk.containsKey('x') && jwk.containsKey('y') && curve != null + ? EcPublicKey( + xCoordinate: _base64ToInt(jwk['x']), + yCoordinate: _base64ToInt(jwk['y']), + curve: curve, + ) + : null, + ); + case 'OKP': + // RFC 8037 §2: `x`/`d` are base64url of the RAW octet string, decoded + // as bytes (NOT via `_base64ToInt`, which would corrupt the key). + final curve = _parseOkpCurve(jwk['crv']); + if (curve == null) return null; + return KeyPair( + publicKey: jwk.containsKey('x') + ? OkpPublicKey( + rawBytes: Uint8List.fromList(_base64ToBytes(jwk['x'])), + curve: curve, + ) + : null, + privateKey: jwk.containsKey('d') + ? OkpPrivateKey( + rawBytes: Uint8List.fromList(_base64ToBytes(jwk['d'])), + curve: curve, + ) + : null, + ); + } + return null; + } + + /// Creates a [Signer] using the private key and the specified algorithm. + Signer createSigner(Identifier algorithm) { + if (privateKey == null) { + throw StateError('Need a private key to create a signer.'); + } + return privateKey!.createSigner(algorithm); + } + + /// Creates a signature [Verifier] using the public key and the specified + /// algorithm + Verifier createVerifier(Identifier algorithm) { + if (publicKey == null) { + throw StateError('Need a public key to create a verifier.'); + } + return publicKey!.createVerifier(algorithm); + } +} + +List _base64ToBytes(String encoded) { + encoded += List.filled((4 - encoded.length % 4) % 4, '=').join(); + return base64Url.decode(encoded); +} + +BigInt _base64ToInt(String encoded) { + final b256 = BigInt.from(256); + return _base64ToBytes(encoded) + .fold(BigInt.zero, (a, b) => a * b256 + BigInt.from(b)); +} + +Identifier? _parseCurve(String name) { + var v = { + 'P-256': curves.p256, + 'P-256K': curves.p256k, + 'P-384': curves.p384, + 'P-521': curves.p521, + }[name]; + return v; +} + +Identifier? _parseOkpCurve(String? name) { + return { + 'Ed25519': curves.ed25519, + }[name]; +} diff --git a/packages/crypto_keys_plus/lib/src/okp_keys.dart b/packages/crypto_keys_plus/lib/src/okp_keys.dart new file mode 100644 index 00000000..f347beca --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/okp_keys.dart @@ -0,0 +1,35 @@ +part of '../crypto_keys.dart'; + +/// Base class for Octet Key Pair (OKP) keys (RFC 8037), such as Ed25519. +/// +/// Unlike [RsaKey] or [EcKey], an OKP key is represented by a raw octet string +/// ([rawBytes]) rather than big-endian integers. +abstract class OkpKey extends Key { + /// The cryptographic curve used with the key, e.g. [curves.ed25519]. + Identifier get curve; + + /// The raw key octet string (RFC 8037 §2). + /// + /// For Ed25519 this is the 32-byte public key (for an [OkpPublicKey]) or the + /// 32-byte private seed (for an [OkpPrivateKey]). + Uint8List get rawBytes; +} + +/// An Octet Key Pair (OKP) public key, e.g. an Ed25519 public key. +abstract class OkpPublicKey extends OkpKey implements PublicKey { + factory OkpPublicKey({ + required Uint8List rawBytes, + required Identifier curve, + }) = OkpPublicKeyImpl; +} + +/// An Octet Key Pair (OKP) private key, e.g. an Ed25519 private key. +/// +/// For Ed25519, [rawBytes] holds the RFC 8032 32-byte private seed (the JWK +/// `d` parameter), not the expanded private key. +abstract class OkpPrivateKey extends OkpKey implements PrivateKey { + factory OkpPrivateKey({ + required Uint8List rawBytes, + required Identifier curve, + }) = OkpPrivateKeyImpl; +} diff --git a/packages/crypto_keys_plus/lib/src/operator.dart b/packages/crypto_keys_plus/lib/src/operator.dart new file mode 100644 index 00000000..e3873211 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/operator.dart @@ -0,0 +1,78 @@ +part of '../crypto_keys.dart'; + +/// Base class for cryptographic operations +abstract class Operator { + /// The key used for this operation + final T key; + + /// The algorithm used for this operation + final AlgorithmIdentifier algorithm; + + final pc.Algorithm _algorithm; + + Operator._(this.algorithm, this.key) + : _algorithm = algorithm.createAlgorithm(); +} + +/// Operator for signing +abstract class Signer extends Operator { + Signer._(Identifier algorithm, T key) + : super._(algorithm as AlgorithmIdentifier, key); + + /// Signs the input [data] using the [key] and [algorithm] + Signature sign(List data); +} + +/// Operator for verifying a signature +abstract class Verifier extends Operator { + Verifier._(Identifier algorithm, T key) + : super._(algorithm as AlgorithmIdentifier, key); + + /// Verifies that [signature] is a valid signature for the input [data] using + /// the [key] and [algorithm] + bool verify(Uint8List data, Signature signature); +} + +/// Represents the result of signing some data +abstract class Signature { + /// Byte representation of the signature + Uint8List get data; + + factory Signature(Uint8List data) = SignatureImpl; +} + +/// Operator for encrypting and decrypting data +abstract class Encrypter extends Operator { + Encrypter._(Identifier algorithm, T key) + : super._(algorithm as AlgorithmIdentifier, key); + + /// Encrypts the input data using the [key] and [algorithm] + /// + /// When the algorithm requires an initialization vector and none is provided, + /// a random initialization vector is generated. + EncryptionResult encrypt(Uint8List input, + {Uint8List? initializationVector, + Uint8List? additionalAuthenticatedData}); + + /// Decrypts the input data using the [key] and [algorithm] + Uint8List decrypt(EncryptionResult input); +} + +/// Represents the result of encrypting some data +abstract class EncryptionResult { + /// Byte representation of the ciphertext + Uint8List get data; + + /// The initialization vector used for encrypting when required by the + /// algorithm + Uint8List? get initializationVector; + + Uint8List? get authenticationTag; + + Uint8List? get additionalAuthenticatedData; + + factory EncryptionResult(Uint8List data, + {Uint8List? initializationVector, + Uint8List? authenticationTag, + Uint8List? additionalAuthenticatedData}) = EncryptionResultImpl; +} diff --git a/packages/crypto_keys_plus/lib/src/pointycastle_ext.dart b/packages/crypto_keys_plus/lib/src/pointycastle_ext.dart new file mode 100644 index 00000000..a102cf99 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/pointycastle_ext.dart @@ -0,0 +1,285 @@ +import 'dart:typed_data'; + +import 'package:pointycastle/export.dart'; + +class ParametersWithIVAndAad + extends ParametersWithIV { + final Uint8List aad; + + ParametersWithIVAndAad( + UnderlyingParameters parameters, Uint8List iv, this.aad) + : super(parameters, iv); +} + +String toHex(Iterable bytes) { + return bytes.map((b) => (b + 256).toRadixString(16).substring(1)).join(); +} + +abstract class BlockCipherWithAuthenticationTag implements BlockCipher { + bool? _encrypting; + Uint8List? _aad; + Uint8List? _iv; + + bool? get isEncrypting => _encrypting; + + Uint8List? get aad => _aad; + + Uint8List? get iv => _iv; + + int get tagLength; + + @override + Uint8List process(Uint8List data) { + if (isEncrypting!) { + var ciphertext = processBlocks(data); + var tag = finalizeTag(); + var out = Uint8List(ciphertext.length + tag.length); + out.setAll(0, ciphertext); + out.setAll(ciphertext.length, tag); + return out; + } else { + var ciphertext = Uint8List.view( + data.buffer, data.offsetInBytes, data.length - tagLength); + var inTag = + Uint8List.view(data.buffer, data.offsetInBytes + ciphertext.length); + var plaintext = processBlocks(ciphertext); + + var tag = finalizeTag(); + + if (!_compareList(inTag, tag)) { + throw 'Auth error'; // TODO + } + return plaintext; + } + } + + Uint8List processBlocks(Uint8List data) { + var inputBlocks = (data.length + blockSize - 1) ~/ blockSize; + + var out = Uint8List(data.length); + + for (var i = 0; i < inputBlocks; i++) { + var offset = (i * blockSize); + processBlock(data, offset, out, offset); + } + + return out; + } + + Uint8List finalizeTag(); + + void initParameters(CipherParameters? parameters); + + @override + void init(bool forEncryption, covariant ParametersWithIVAndAad params) { + _encrypting = forEncryption; + _aad = params.aad; + _iv = params.iv; + initParameters(params.parameters); + } + + static bool _compareList(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +class AesCbcAuthenticatedCipherWithHash + extends BlockCipherWithAuthenticationTag { + final BlockCipher _underlyingCipher = + PaddedBlockCipherImpl(PKCS7Padding(), CBCBlockCipher(AESEngine())); + + final Mac _underlyingMac; + + AesCbcAuthenticatedCipherWithHash(this._underlyingMac); + + @override + String get algorithmName => + '${_underlyingCipher.algorithmName}+${_underlyingMac.algorithmName}'; + + @override + int get blockSize => _underlyingCipher.blockSize; + + @override + void initParameters(covariant KeyParameter parameters) { + var key = parameters.key; + + var macKey = Uint8List.view(key.buffer, key.offsetInBytes, key.length ~/ 2); + var encKey = + Uint8List.view(key.buffer, key.offsetInBytes + key.length ~/ 2); + + _underlyingMac.init(KeyParameter(macKey)); + _underlyingCipher.init( + isEncrypting!, + PaddedBlockCipherParameters( + ParametersWithIV( + KeyParameter(encKey), + iv!, + ), + null)); + } + + @override + int processBlock(Uint8List inp, int inpOff, Uint8List out, int outOff) { + throw UnsupportedError('Should not be called'); + } + + @override + void reset() { + throw UnsupportedError('Should not be called'); + } + + late Uint8List _hash; + + @override + Uint8List processBlocks(Uint8List data) { + var out = _underlyingCipher.process(data); + + var ciphertext = isEncrypting! ? out : data; + + var hashInput = Uint8List(aad!.length + iv!.length + ciphertext.length + 8); + + var al = aad!.length * 8; + hashInput.setAll(0, aad!); + hashInput.setAll(aad!.length, iv!); + hashInput.setAll(aad!.length + iv!.length, ciphertext); + for (var i = hashInput.length - 1; i > (hashInput.length - 8); i--) { + hashInput[i] = al % 256; + al >>= 8; + } + + _hash = _underlyingMac.process(hashInput); + + return out; + } + + @override + Uint8List finalizeTag() => + Uint8List.view(_hash.buffer, _hash.offsetInBytes, tagLength); + + @override + int get tagLength => _underlyingMac.macSize ~/ 2; +} + +class AESKeyWrap implements BlockCipher { + final BlockCipher _underlyingCipher = AESEngine(); + + @override + String get algorithmName => 'AESWrap'; + + @override + int get blockSize => 8; + + static final Uint8List _iv = Uint8List.fromList([ + 0xa6, + 0xa6, + 0xa6, + 0xa6, + 0xa6, + 0xa6, + 0xa6, + 0xa6, + ]); + late bool _encrypting; + + @override + void init(bool forEncryption, covariant KeyParameter? params) { + _encrypting = forEncryption; + _underlyingCipher.init(forEncryption, params); + } + + Uint8List wrap(Uint8List data) { + var n = data.length ~/ 8; + + var r = Uint8List.fromList(data); + + var a = Uint8List(16); + + var b = Uint8List(16); + var b64 = ByteData.view(b.buffer); + + a.setAll(0, _iv); + + for (var j = 0; j <= 5; j++) { + for (var i = 0; i < n; i++) { + var t = n * j + i + 1; + + a.setAll(8, r.skip(i * 8).take(8)); + + _underlyingCipher.processBlock(a, 0, b, 0); + + b64.setUint32(0, b64.getUint32(0) ^ (t << 32)); + b64.setUint32(4, b64.getUint32(4) ^ (t & 0xffffffff)); + + a.setAll(0, b.take(8)); + r.setAll(i * 8, b.skip(8)); + } + } + + var c = Uint8List(n * 8 + 8); + c.setAll(0, a.take(8)); + c.setAll(8, r); + + return c; + } + + Uint8List unwrap(Uint8List data) { + var n = data.length ~/ 8 - 1; + + var a = Uint8List(16); + a.setAll(0, data.take(8)); + var a64 = ByteData.view(a.buffer); + + var b = Uint8List(16); + + var r = Uint8List(n * 8); + r.setAll(0, data.skip(8)); + + for (var j = 5; j >= 0; j--) { + for (var i = n - 1; i >= 0; i--) { + var t = n * j + i + 1; + + a64.setInt32(0, a64.getInt32(0) ^ (t << 32)); + a64.setInt32(4, a64.getInt32(4) ^ (t & 0xffffffff)); + a.setAll(8, r.skip(i * 8).take(8)); + + _underlyingCipher.processBlock(a, 0, b, 0); + a.setAll(0, b.take(8)); + r.setAll(i * 8, b.skip(8)); + } + } + + for (var i = 0; i < 8; i++) { + if (_iv[i] != a[i]) { + throw 'Invalid '; // TODO + } + } + return r; + } + + @override + Uint8List process(Uint8List data) { + if (data.length % 8 != 0) { + throw ArgumentError('Input data should be a multiple of 64 bits.'); + } + + if (_encrypting) { + return wrap(data); + } else { + return unwrap(data); + } + } + + @override + int processBlock(Uint8List inp, int inpOff, Uint8List out, int outOff) { + throw UnsupportedError('Should not be called.'); + } + + @override + void reset() { + throw UnsupportedError('Should not be called.'); + } +} diff --git a/packages/crypto_keys_plus/lib/src/rsa_keys.dart b/packages/crypto_keys_plus/lib/src/rsa_keys.dart new file mode 100644 index 00000000..baac0357 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/rsa_keys.dart @@ -0,0 +1,34 @@ +part of '../crypto_keys.dart'; + +/// Base class for RSA keys +abstract class RsaKey extends Key { + /// The modulus value for the RSA key + BigInt get modulus; +} + +/// A RSA public key +abstract class RsaPublicKey extends RsaKey implements PublicKey { + /// The exponent value for the RSA public key + BigInt get exponent; + + factory RsaPublicKey({required BigInt modulus, required BigInt exponent}) = + RsaPublicKeyImpl; +} + +/// A RSA private key +abstract class RsaPrivateKey extends RsaKey implements PrivateKey { + /// The private exponent value for the RSA private key + BigInt get privateExponent; + + /// The first prime factor + BigInt get firstPrimeFactor; + + /// The second prime factor + BigInt get secondPrimeFactor; + + factory RsaPrivateKey( + {required BigInt privateExponent, + required BigInt firstPrimeFactor, + required BigInt secondPrimeFactor, + required BigInt modulus}) = RsaPrivateKeyImpl; +} diff --git a/packages/crypto_keys_plus/lib/src/symmetric_keys.dart b/packages/crypto_keys_plus/lib/src/symmetric_keys.dart new file mode 100644 index 00000000..9964f225 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/symmetric_keys.dart @@ -0,0 +1,18 @@ +part of '../crypto_keys.dart'; + +/// A symmetric key +abstract class SymmetricKey extends Key with PublicKey, PrivateKey { + /// The value of the symmetric (or other single-valued) key + Uint8List get keyValue; + + factory SymmetricKey({required Uint8List keyValue}) = SymmetricKeyImpl; + + factory SymmetricKey.generate(int bitLength) { + if (bitLength % 8 != 0) { + throw ArgumentError( + 'Illegal bit length $bitLength, should be mutiple of 8.'); + } + var value = DefaultSecureRandom().nextBytes(bitLength ~/ 8); + return SymmetricKey(keyValue: value); + } +} diff --git a/packages/crypto_keys_plus/lib/src/symmetric_operator.dart b/packages/crypto_keys_plus/lib/src/symmetric_operator.dart new file mode 100644 index 00000000..2e875ba6 --- /dev/null +++ b/packages/crypto_keys_plus/lib/src/symmetric_operator.dart @@ -0,0 +1,95 @@ +part of '../crypto_keys.dart'; + +class _SymmetricSignerAndVerifier extends Signer + implements Verifier { + _SymmetricSignerAndVerifier(Identifier algorithm, SymmetricKey key) + : super._(algorithm, key); + + @override + pc.Mac get _algorithm => super._algorithm as pc.Mac; + + @override + Signature sign(List data) { + data = data is Uint8List ? data : Uint8List.fromList(data); + _algorithm.init(pc.KeyParameter(key.keyValue)); + return Signature(_algorithm.process(data)); + } + + @override + bool verify(Uint8List data, Signature signature) => sign(data) == signature; +} + +class _SymmetricEncrypter extends Encrypter { + _SymmetricEncrypter(Identifier algorithm, SymmetricKey key) + : super._(algorithm, key); + + @override + pc.BlockCipher get _algorithm => super._algorithm as pc.BlockCipher; + + pc.CipherParameters _getParams( + Uint8List? initializationVector, Uint8List? additionalAuthenticatedData) { + var keyParam = pc.KeyParameter(key.keyValue); + + if (_algorithm is pc.AESKeyWrap) return keyParam; + if (_algorithm is pc.GCMBlockCipher) { + return pc.AEADParameters(keyParam, 128, initializationVector!, + additionalAuthenticatedData ?? Uint8List(0)); + } + + var paramsWithIV = pc.ParametersWithIVAndAad(keyParam, + initializationVector!, additionalAuthenticatedData ?? Uint8List(0)); + + if (_algorithm is pc.PaddedBlockCipher) { + return pc.PaddedBlockCipherParameters(paramsWithIV, null); + } + + return paramsWithIV; + } + + @override + Uint8List decrypt(EncryptionResult input) { + _algorithm.init( + false, + _getParams( + input.initializationVector, input.additionalAuthenticatedData)); + var data = input.data; + if (input.authenticationTag != null) { + data = Uint8List(data.length + input.authenticationTag!.length); + data.setAll(0, input.data); + data.setAll(input.data.length, input.authenticationTag!); + } + return _algorithm.process(data); + } + + @override + EncryptionResult encrypt(Uint8List input, + {Uint8List? initializationVector, + Uint8List? additionalAuthenticatedData}) { + initializationVector ??= + DefaultSecureRandom().nextBytes(_algorithm.blockSize); + + _algorithm.init( + true, _getParams(initializationVector, additionalAuthenticatedData)); + var r = _algorithm.process(input); + Uint8List? tag; + if (_algorithm is pc.GCMBlockCipher) { + var tagLength = 16; + tag = Uint8List.view( + r.buffer, r.offsetInBytes + r.length - tagLength, tagLength); + r = Uint8List.view(r.buffer, r.offsetInBytes, r.length - tagLength); + } + if (_algorithm is pc.BlockCipherWithAuthenticationTag) { + var tagLength = + (_algorithm as pc.BlockCipherWithAuthenticationTag).tagLength; + tag = Uint8List.view( + r.buffer, r.offsetInBytes + r.length - tagLength, tagLength); + r = Uint8List.view(r.buffer, r.offsetInBytes, r.length - tagLength); + } + + return EncryptionResult(r, + initializationVector: + _algorithm is pc.AESKeyWrap ? null : initializationVector, + additionalAuthenticatedData: additionalAuthenticatedData, + authenticationTag: tag); + } +} diff --git a/packages/crypto_keys_plus/pubspec.yaml b/packages/crypto_keys_plus/pubspec.yaml new file mode 100644 index 00000000..07009dae --- /dev/null +++ b/packages/crypto_keys_plus/pubspec.yaml @@ -0,0 +1,28 @@ +name: crypto_keys_plus +description: A library for doing cryptographic signing/verifying and encrypting/decrypting. +version: 1.0.0 +repository: https://github.com/Bdaya-Dev/oidc/tree/main/packages/crypto_keys_plus +homepage: https://bdaya-dev.github.io/oidc/ +funding: + - https://github.com/sponsors/rbellens +resolution: workspace + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + pointycastle: ^4.0.0 + collection: ^1.14.13 + # Pinned to the 0.3.x range: pre-1.0, third-party pure-Dart Ed25519 port + # (no FFI, web/wasm-safe) with a stale SDK bound. Caret constraint on a + # pre-1.0 version still forbids drifting to 0.4.0+, so this can't pick up + # an unreviewed breaking resolution bump; 0.3.1 remains the latest release + # on pub.dev, so it resolves identically today. + # TODO(#324): evaluate migrating to a maintained, audited Ed25519 + # (e.g. cryptography_plus). + ed25519_edwards: ^0.3.1 + +dev_dependencies: + meta: ^1.3.0 + test: ^1.16.2 + lints: ">=4.0.0 <7.0.0" diff --git a/packages/crypto_keys_plus/test/coverage_gaps_test.dart b/packages/crypto_keys_plus/test/coverage_gaps_test.dart new file mode 100644 index 00000000..5b246871 --- /dev/null +++ b/packages/crypto_keys_plus/test/coverage_gaps_test.dart @@ -0,0 +1,353 @@ +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:test/test.dart'; + +/// Targeted tests for previously-uncovered branches: the algorithm registry +/// tables, JWA name lookup, key value semantics (== / hashCode), error paths in +/// the operators, and the AES-CBC-HMAC decrypt / AES-KeyWrap guards. + +/// An `Object`-typed value of an unrelated runtime type, used to drive the +/// `other is XKey` false branch of the various `operator ==` overrides without +/// tripping the `unrelated_type_equality_checks` lint. +final Object _unrelated = ['not a key']; + +void main() { + group('digest algorithm registry', () { + // Exercising createAlgorithm() runs each factory closure and returns a real + // pointycastle digest, so we can assert its identity and output size. + test('SHA family maps to correct digest sizes', () { + expect(algorithms.digest.sha1.createAlgorithm().digestSize, 20); + expect(algorithms.digest.sha224.createAlgorithm().digestSize, 28); + expect(algorithms.digest.sha256.createAlgorithm().digestSize, 32); + expect(algorithms.digest.sha384.createAlgorithm().digestSize, 48); + expect(algorithms.digest.sha512.createAlgorithm().digestSize, 64); + }); + + test('SHA names are canonical', () { + expect(algorithms.digest.sha1.createAlgorithm().algorithmName, 'SHA-1'); + expect( + algorithms.digest.sha256.createAlgorithm().algorithmName, 'SHA-256'); + expect( + algorithms.digest.sha512.createAlgorithm().algorithmName, 'SHA-512'); + }); + + test('MD family maps to correct digest sizes', () { + expect(algorithms.digest.md2.createAlgorithm().digestSize, 16); + expect(algorithms.digest.md4.createAlgorithm().digestSize, 16); + expect(algorithms.digest.md5.createAlgorithm().digestSize, 16); + }); + + test('SHA-512/t honours the requested truncated size', () { + final d = algorithms.digest.sha512t(28); + expect(d.name, 'digest/SHA-512/224'); + // sha512t is declared as the base AlgorithmIdentifier, so + // reach digestSize dynamically. + expect((d.createAlgorithm() as dynamic).digestSize, 28); + }); + }); + + group('encryption algorithm registry', () { + test('deprecated aes cbc getter aliases encryption.aes.cbc', () { + // ignore: deprecated_member_use + expect( + algorithms.encrypting_aes_cbc, same(algorithms.encryption.aes.cbc)); + }); + + test('AES EAX factory is unimplemented', () { + expect(algorithms.encryption.aes.eax.createAlgorithm, + throwsUnimplementedError); + }); + + test('AES key wrap and rsa identifiers expose their names', () { + expect(algorithms.encryption.aes.keyWrap.name, 'enc/AES/KW'); + expect(algorithms.encryption.rsa.pkcs1.name, 'enc/RSA/PKCS1'); + expect(algorithms.encryption.aes.keyWrap.createAlgorithm().algorithmName, + 'AESWrap'); + }); + + test('hybrid withParameters builds a HKDF key derivator', () { + final id = algorithms.encryption.hybrid.withParameters( + keySize: 256, + curve: curves.p256, + hkdfHash: algorithms.digest.sha256, + ); + expect(id.name, 'enc/hybrid'); + expect(id.createAlgorithm().algorithmName, contains('HKDF')); + }); + }); + + group('EdDSA registry placeholder', () { + test('Ed25519 placeholder algorithm reports its name', () { + expect(algorithms.signing.eddsa.ed25519.createAlgorithm().algorithmName, + 'Ed25519'); + expect(algorithms.signing.eddsa.ed25519.name, 'sig/EdDSA/Ed25519'); + }); + }); + + group('AlgorithmIdentifier.getByJwaName', () { + test('resolves known JWS/JWE identifiers', () { + expect(AlgorithmIdentifier.getByJwaName('HS256'), + same(algorithms.signing.hmac.sha256)); + expect(AlgorithmIdentifier.getByJwaName('RS512'), + same(algorithms.signing.rsa.sha512)); + expect(AlgorithmIdentifier.getByJwaName('ES256'), + same(algorithms.signing.ecdsa.sha256)); + expect(AlgorithmIdentifier.getByJwaName('PS384'), + same(algorithms.signing.rsa.pss.sha384)); + expect(AlgorithmIdentifier.getByJwaName('EdDSA'), + same(algorithms.signing.eddsa.ed25519)); + expect(AlgorithmIdentifier.getByJwaName('A128GCM'), + same(algorithms.encryption.aes.gcm)); + expect(AlgorithmIdentifier.getByJwaName('A256CBC-HS512'), + same(algorithms.encryption.aes.cbcWithHmac.sha512)); + }); + + test('returns null for the "none" algorithm', () { + expect(AlgorithmIdentifier.getByJwaName('none'), isNull); + }); + + test('throws UnimplementedError for a known-but-unimplemented alg', () { + // 'dir' is present in the table mapped to null (a placeholder), so it is + // reported as not-yet-implemented rather than unsupported. + expect(() => AlgorithmIdentifier.getByJwaName('dir'), + throwsA(isA())); + expect(() => AlgorithmIdentifier.getByJwaName('ECDH-ES'), + throwsA(isA())); + }); + + test('throws UnsupportedError for an entirely unknown alg', () { + expect(() => AlgorithmIdentifier.getByJwaName('NOPE-999'), + throwsA(isA())); + }); + }); + + group('key value semantics (== / hashCode)', () { + test('RsaPublicKey', () { + final a = + RsaPublicKey(modulus: BigInt.from(3233), exponent: BigInt.from(17)); + final b = + RsaPublicKey(modulus: BigInt.from(3233), exponent: BigInt.from(17)); + final c = + RsaPublicKey(modulus: BigInt.from(3233), exponent: BigInt.from(19)); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + expect(identical(a, a) && a == a, isTrue); + }); + + test('RsaPrivateKey', () { + RsaPrivateKey make(BigInt d) => RsaPrivateKey( + privateExponent: d, + firstPrimeFactor: BigInt.from(61), + secondPrimeFactor: BigInt.from(53), + modulus: BigInt.from(3233), + ); + final a = make(BigInt.from(413)); + final b = make(BigInt.from(413)); + final c = make(BigInt.from(999)); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + + test('EcPublicKey', () { + EcPublicKey make(BigInt x) => EcPublicKey( + xCoordinate: x, yCoordinate: BigInt.two, curve: curves.p256); + final a = make(BigInt.one); + final b = make(BigInt.one); + final c = make(BigInt.from(7)); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + // Same coordinates, different curve -> not equal. + expect( + a, + isNot(equals(EcPublicKey( + xCoordinate: BigInt.one, + yCoordinate: BigInt.two, + curve: curves.p384)))); + expect(a == _unrelated, isFalse); + }); + + test('EcPrivateKey', () { + final a = EcPrivateKey(eccPrivateKey: BigInt.from(5), curve: curves.p256); + final b = EcPrivateKey(eccPrivateKey: BigInt.from(5), curve: curves.p256); + final c = EcPrivateKey(eccPrivateKey: BigInt.from(6), curve: curves.p256); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect( + a, + isNot(equals(EcPrivateKey( + eccPrivateKey: BigInt.from(5), curve: curves.p384)))); + expect(a == _unrelated, isFalse); + }); + + test('OkpPublicKey', () { + OkpPublicKey make(List raw, [Identifier? crv]) => OkpPublicKey( + rawBytes: Uint8List.fromList(raw), curve: crv ?? curves.ed25519); + final a = make([1, 2, 3]); + final b = make([1, 2, 3]); + final c = make([1, 2, 4]); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + + test('OkpPrivateKey', () { + OkpPrivateKey make(List raw) => OkpPrivateKey( + rawBytes: Uint8List.fromList(raw), curve: curves.ed25519); + final a = make([9, 8, 7]); + final b = make([9, 8, 7]); + final c = make([9, 8, 6]); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + + test('SymmetricKey', () { + SymmetricKey make(List raw) => + SymmetricKey(keyValue: Uint8List.fromList(raw)); + final a = make([0, 1, 2, 3]); + final b = make([0, 1, 2, 3]); + final c = make([0, 1, 2, 4]); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + + test('Signature', () { + final a = Signature(Uint8List.fromList([1, 2, 3])); + final b = Signature(Uint8List.fromList([1, 2, 3])); + final c = Signature(Uint8List.fromList([1, 2, 9])); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + + test('EncryptionResult', () { + EncryptionResult make(List data) => EncryptionResult( + Uint8List.fromList(data), + initializationVector: Uint8List.fromList([9, 9]), + authenticationTag: Uint8List.fromList([8]), + additionalAuthenticatedData: Uint8List.fromList([7, 6]), + ); + final a = make([1, 2, 3]); + final b = make([1, 2, 3]); + final c = make([1, 2, 4]); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + expect(a == _unrelated, isFalse); + }); + }); + + group('operator error paths', () { + test('generateEc rejects an unsupported curve', () { + // curves.ed25519 is not an EC curve, so createCurveParameters throws. + expect(() => KeyPair.generateEc(curves.ed25519), throwsArgumentError); + }); + + test('EdDSA signer/verifier reject a non-Ed25519 OKP curve', () { + final priv = OkpPrivateKey( + rawBytes: Uint8List(32), curve: curves.p256); // wrong curve + final pub = OkpPublicKey(rawBytes: Uint8List(32), curve: curves.p256); + expect( + () => priv.createSigner(algorithms.signing.eddsa.ed25519).sign([1]), + throwsA(isA())); + expect( + () => pub.createVerifier(algorithms.signing.eddsa.ed25519).verify( + Uint8List.fromList([1]), Signature(Uint8List.fromList([0]))), + throwsA(isA())); + }); + + test('SymmetricKey.generate rejects a non-byte-aligned bit length', () { + expect(() => SymmetricKey.generate(100), throwsArgumentError); + }); + + test('KeyPair without a private key cannot create a signer', () { + final pair = KeyPair( + publicKey: + RsaPublicKey(modulus: BigInt.from(3233), exponent: BigInt.from(17)), + privateKey: null, + ); + expect(() => pair.createSigner(algorithms.signing.rsa.sha256), + throwsStateError); + }); + + test('KeyPair without a public key cannot create a verifier', () { + final pair = KeyPair( + publicKey: null, + privateKey: RsaPrivateKey( + privateExponent: BigInt.from(413), + firstPrimeFactor: BigInt.from(61), + secondPrimeFactor: BigInt.from(53), + modulus: BigInt.from(3233), + ), + ); + expect(() => pair.createVerifier(algorithms.signing.rsa.sha256), + throwsStateError); + }); + }); + + group('RSA operators with a generated key', () { + // One 2048-bit key shared by the RSA-specific paths below. + final rsa = KeyPair.generateRsa(bitStrength: 2048); + final message = Uint8List.fromList([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + + test('verify returns false for an oversized/garbage signature', () { + final verifier = rsa.createVerifier(algorithms.signing.rsa.sha256); + // A signature far larger than the modulus makes pointycastle throw an + // ArgumentError internally; verify() must swallow it and return false. + final bogus = Signature(Uint8List(400)..fillRange(0, 400, 0xff)); + expect(verifier.verify(message, bogus), isFalse); + }); + + test('RSASSA-PSS with an explicit custom salt length round-trips', () { + final alg = algorithms.signing.rsa.pss.withParameters( + sigHash: algorithms.digest.sha256, + mgf1Hash: algorithms.digest.sha256, + saltLength: 48, + ); + expect(alg.name, contains('/salt48')); + // Building the signer runs the withParameters factory closure. + final signer = rsa.createSigner(alg); + final verifier = rsa.createVerifier(alg); + final sig = signer.sign(message); + expect(verifier.verify(message, sig), isTrue); + // Tampered signature must fail. + final tampered = Signature(Uint8List.fromList(sig.data)..[0] ^= 0x01); + expect(verifier.verify(message, tampered), isFalse); + }); + }); + + group('symmetric AEAD guards', () { + test('AES-128-CBC-HMAC-SHA-256 encrypt/decrypt round trip', () { + final key = SymmetricKey.generate(256); // 16-byte mac + 16-byte enc + final enc = + key.createEncrypter(algorithms.encryption.aes.cbcWithHmac.sha256); + final plaintext = + Uint8List.fromList(List.generate(40, (i) => i & 0xff)); + final result = enc.encrypt(plaintext, + additionalAuthenticatedData: Uint8List.fromList([1, 2, 3, 4])); + expect(result.authenticationTag, isNotNull); + // Decrypt drives the auth-tag verification branch of process(). + expect(enc.decrypt(result), equals(plaintext)); + }); + + test('AES key wrap rejects input that is not a multiple of 64 bits', () { + final kek = SymmetricKey.generate(128); + final enc = kek.createEncrypter(algorithms.encryption.aes.keyWrap); + // 5 bytes is not a multiple of 8 -> ArgumentError from AESKeyWrap.process. + expect(() => enc.encrypt(Uint8List.fromList([1, 2, 3, 4, 5])), + throwsArgumentError); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/crypto_keys_test.dart b/packages/crypto_keys_plus/test/crypto_keys_test.dart new file mode 100644 index 00000000..09282274 --- /dev/null +++ b/packages/crypto_keys_plus/test/crypto_keys_test.dart @@ -0,0 +1,2788 @@ +import 'package:test/test.dart'; +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'dart:typed_data'; +import 'dart:convert'; + +void _testSigning( + KeyPair keyPair, AlgorithmIdentifier algorithm, Uint8List data, + [Uint8List? signature, bool? isRandom]) { + var signer = keyPair.createSigner(algorithm); + var verifier = keyPair.createVerifier(algorithm); + + if (signature != null) { + expect(verifier.verify(data, Signature(signature)), isTrue); + + if (!isRandom!) { + expect(signer.sign(data).data, signature); + } + } + + var s = signer.sign(data); + expect(verifier.verify(data, s), isTrue); + s = Signature(s.data..[0] += 1); + expect(verifier.verify(data, s), isFalse); +} + +void _testEncryption( + KeyPair keyPair, + AlgorithmIdentifier algorithm, + Uint8List data, [ + EncryptionResult? encryptedData, + bool? isRandom, + bool biDirectional = true, +]) { + var encrypter = keyPair.publicKey!.createEncrypter(algorithm); + var decrypter = keyPair.privateKey!.createEncrypter(algorithm); + + if (encryptedData != null) { + expect(decrypter.decrypt(encryptedData), data); + + if (!isRandom!) { + expect( + encrypter.encrypt(data, + initializationVector: encryptedData.initializationVector, + additionalAuthenticatedData: + encryptedData.additionalAuthenticatedData), + encryptedData); + } + } + + expect( + decrypter.decrypt(encrypter.encrypt(data, + initializationVector: encryptedData?.initializationVector, + additionalAuthenticatedData: + encryptedData?.additionalAuthenticatedData)), + data); + + if (biDirectional == true) { + expect( + encrypter.decrypt( + decrypter.encrypt( + data, + initializationVector: encryptedData?.initializationVector, + additionalAuthenticatedData: + encryptedData?.additionalAuthenticatedData, + ), + ), + data, + ); + } +} + +void main() { + group('Signing', () { + group('Signing with symmetric keys', () { + var jwk = { + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + }; + + var keyPair = KeyPair.fromJwk(jwk); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + + var data = Uint8List.fromList([ + 101, + 121, + 74, + 48, + 101, + 88, + 65, + 105, + 79, + 105, + 74, + 75, + 86, + 49, + 81, + 105, + 76, + 65, + 48, + 75, + 73, + 67, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 73, + 85, + 122, + 73, + 49, + 78, + 105, + 74, + 57, + 46, + 101, + 121, + 74, + 112, + 99, + 51, + 77, + 105, + 79, + 105, + 74, + 113, + 98, + 50, + 85, + 105, + 76, + 65, + 48, + 75, + 73, + 67, + 74, + 108, + 101, + 72, + 65, + 105, + 79, + 106, + 69, + 122, + 77, + 68, + 65, + 52, + 77, + 84, + 107, + 122, + 79, + 68, + 65, + 115, + 68, + 81, + 111, + 103, + 73, + 109, + 104, + 48, + 100, + 72, + 65, + 54, + 76, + 121, + 57, + 108, + 101, + 71, + 70, + 116, + 99, + 71, + 120, + 108, + 76, + 109, + 78, + 118, + 98, + 83, + 57, + 112, + 99, + 49, + 57, + 121, + 98, + 50, + 57, + 48, + 73, + 106, + 112, + 48, + 99, + 110, + 86, + 108, + 102, + 81 + ]); + + test('Example Signing Using HMAC SHA-256', () { + var signature = Uint8List.fromList([ + 116, + 24, + 223, + 180, + 151, + 153, + 224, + 37, + 79, + 250, + 96, + 125, + 216, + 173, + 187, + 186, + 22, + 212, + 37, + 77, + 105, + 214, + 191, + 240, + 91, + 88, + 5, + 88, + 83, + 132, + 141, + 121 + ]); + + _testSigning( + keyPair, algorithms.signing.hmac.sha256, data, signature, false); + }); + test('Example Signing Using HMAC SHA-384', () { + _testSigning(keyPair, algorithms.signing.hmac.sha384, data); + }); + test('Example Signing Using HMAC SHA-512', () { + _testSigning(keyPair, algorithms.signing.hmac.sha512, data); + }); + }); + + group('Signing with RSA keys', () { + var jwk = { + 'kty': 'RSA', + 'n': 'ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddx' + 'HmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMs' + 'D1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSH' + 'SXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdV' + 'MTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8' + 'NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ', + 'e': 'AQAB', + 'd': 'Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97I' + 'jlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0' + 'BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn' + '439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYT' + 'CBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLh' + 'BOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ', + 'p': '4BzEEOtIpmVdVEZNCqS7baC4crd0pqnRH_5IB3jw3bcxGn6QLvnEtfdUdi' + 'YrqBdss1l58BQ3KhooKeQTa9AB0Hw_Py5PJdTJNPY8cQn7ouZ2KKDcmnPG' + 'BY5t7yLc1QlQ5xHdwW1VhvKn-nXqhJTBgIPgtldC-KDV5z-y2XDwGUc', + 'q': 'uQPEfgmVtjL0Uyyx88GZFF1fOunH3-7cepKmtH4pxhtCoHqpWmT8YAmZxa' + 'ewHgHAjLYsp1ZSe7zFYHj7C6ul7TjeLQeZD_YwD66t62wDmpe_HlB-TnBA' + '-njbglfIsRLtXlnDzQkv5dTltRJ11BKBBypeeF6689rjcJIDEz9RWdc', + 'dp': 'BwKfV3Akq5_MFZDFZCnW-wzl-CCo83WoZvnLQwCTeDv8uzluRSnm71I3Q' + 'CLdhrqE2e9YkxvuxdBfpT_PI7Yz-FOKnu1R6HsJeDCjn12Sk3vmAktV2zb' + '34MCdy7cpdTh_YVr7tss2u6vneTwrA86rZtu5Mbr1C1XsmvkxHQAdYo0', + 'dq': 'h_96-mK1R_7glhsum81dZxjTnYynPbZpHziZjeeHcXYsXaaMwkOlODsWa' + '7I9xXDoRwbKgB719rrmI2oKr6N3Do9U0ajaHF-NKJnwgjMd2w9cjz3_-ky' + 'NlxAr2v4IKhGNpmM5iIgOS1VZnOZ68m6_pbLBSp3nssTdlqvd0tIiTHU', + 'qi': 'IYd7DHOhrWvxkwPQsRM2tOgrjbcrfvtQJipd-DlcxyVuuM9sQLdgjVk2o' + 'y26F0EmpScGLq2MowX7fhd_QJQ3ydy5cY7YIBi87w93IKLEdfnbJtoOPLU' + 'W0ITrJReOgo1cq9SbsxYawBgfp_gh6A5603k2-ZQwVK0JKSHuLFkuQ3U' + }; + + var keyPair = KeyPair.fromJwk(jwk); + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + var data = Uint8List.fromList([ + 101, + 121, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 83, + 85, + 122, + 73, + 49, + 78, + 105, + 74, + 57, + 46, + 101, + 121, + 74, + 112, + 99, + 51, + 77, + 105, + 79, + 105, + 74, + 113, + 98, + 50, + 85, + 105, + 76, + 65, + 48, + 75, + 73, + 67, + 74, + 108, + 101, + 72, + 65, + 105, + 79, + 106, + 69, + 122, + 77, + 68, + 65, + 52, + 77, + 84, + 107, + 122, + 79, + 68, + 65, + 115, + 68, + 81, + 111, + 103, + 73, + 109, + 104, + 48, + 100, + 72, + 65, + 54, + 76, + 121, + 57, + 108, + 101, + 71, + 70, + 116, + 99, + 71, + 120, + 108, + 76, + 109, + 78, + 118, + 98, + 83, + 57, + 112, + 99, + 49, + 57, + 121, + 98, + 50, + 57, + 48, + 73, + 106, + 112, + 48, + 99, + 110, + 86, + 108, + 102, + 81 + ]); + + test('Example Signing Using RSASSA-PKCS1-v1_5 SHA-256', () { + var signature = Uint8List.fromList([ + 112, + 46, + 33, + 137, + 67, + 232, + 143, + 209, + 30, + 181, + 216, + 45, + 191, + 120, + 69, + 243, + 65, + 6, + 174, + 27, + 129, + 255, + 247, + 115, + 17, + 22, + 173, + 209, + 113, + 125, + 131, + 101, + 109, + 66, + 10, + 253, + 60, + 150, + 238, + 221, + 115, + 162, + 102, + 62, + 81, + 102, + 104, + 123, + 0, + 11, + 135, + 34, + 110, + 1, + 135, + 237, + 16, + 115, + 249, + 69, + 229, + 130, + 173, + 252, + 239, + 22, + 216, + 90, + 121, + 142, + 232, + 198, + 109, + 219, + 61, + 184, + 151, + 91, + 23, + 208, + 148, + 2, + 190, + 237, + 213, + 217, + 217, + 112, + 7, + 16, + 141, + 178, + 129, + 96, + 213, + 248, + 4, + 12, + 167, + 68, + 87, + 98, + 184, + 31, + 190, + 127, + 249, + 217, + 46, + 10, + 231, + 111, + 36, + 242, + 91, + 51, + 187, + 230, + 244, + 74, + 230, + 30, + 177, + 4, + 10, + 203, + 32, + 4, + 77, + 62, + 249, + 18, + 142, + 212, + 1, + 48, + 121, + 91, + 212, + 189, + 59, + 65, + 238, + 202, + 208, + 102, + 171, + 101, + 25, + 129, + 253, + 228, + 141, + 247, + 127, + 55, + 45, + 195, + 139, + 159, + 175, + 221, + 59, + 239, + 177, + 139, + 93, + 163, + 204, + 60, + 46, + 176, + 47, + 158, + 58, + 65, + 214, + 18, + 202, + 173, + 21, + 145, + 18, + 115, + 160, + 95, + 35, + 185, + 232, + 56, + 250, + 175, + 132, + 157, + 105, + 132, + 41, + 239, + 90, + 30, + 136, + 121, + 130, + 54, + 195, + 212, + 14, + 96, + 69, + 34, + 165, + 68, + 200, + 242, + 122, + 122, + 45, + 184, + 6, + 99, + 209, + 108, + 247, + 202, + 234, + 86, + 222, + 64, + 92, + 178, + 33, + 90, + 69, + 178, + 194, + 85, + 102, + 181, + 90, + 193, + 167, + 72, + 160, + 112, + 223, + 200, + 163, + 42, + 70, + 149, + 67, + 208, + 25, + 238, + 251, + 71 + ]); + + _testSigning( + keyPair, algorithms.signing.rsa.sha256, data, signature, false); + }); + test('Example Signing Using RSASSA-PKCS1-v1_5 SHA-384', () { + _testSigning(keyPair, algorithms.signing.rsa.sha384, data); + }); + test('Example Signing Using RSASSA-PKCS1-v1_5 SHA-512', () { + _testSigning(keyPair, algorithms.signing.rsa.sha512, data); + }); + + test('Example Signing Using generated RSA keys', () { + var text = 'Can we sign and verify this text'; + var data = utf8.encode(text); + + var keyPair = KeyPair.generateRsa(); + var alg = algorithms.signing.rsa.sha384; + + _testSigning(keyPair, alg, data); + }); + }); + + group('Signing with EC keys', () { + test('Example Signing Using ECDSA P-256 SHA-256', () { + var jwk = { + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y': 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + 'd': 'jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI' + }; + + var keyPair = KeyPair.fromJwk(jwk); + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + var data = Uint8List.fromList([ + 101, + 121, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 70, + 85, + 122, + 73, + 49, + 78, + 105, + 74, + 57, + 46, + 101, + 121, + 74, + 112, + 99, + 51, + 77, + 105, + 79, + 105, + 74, + 113, + 98, + 50, + 85, + 105, + 76, + 65, + 48, + 75, + 73, + 67, + 74, + 108, + 101, + 72, + 65, + 105, + 79, + 106, + 69, + 122, + 77, + 68, + 65, + 52, + 77, + 84, + 107, + 122, + 79, + 68, + 65, + 115, + 68, + 81, + 111, + 103, + 73, + 109, + 104, + 48, + 100, + 72, + 65, + 54, + 76, + 121, + 57, + 108, + 101, + 71, + 70, + 116, + 99, + 71, + 120, + 108, + 76, + 109, + 78, + 118, + 98, + 83, + 57, + 112, + 99, + 49, + 57, + 121, + 98, + 50, + 57, + 48, + 73, + 106, + 112, + 48, + 99, + 110, + 86, + 108, + 102, + 81 + ]); + + var signature = Uint8List.fromList([ + 14, + 209, + 33, + 83, + 121, + 99, + 108, + 72, + 60, + 47, + 127, + 21, + 88, + 7, + 212, + 2, + 163, + 178, + 40, + 3, + 58, + 249, + 124, + 126, + 23, + 129, + 154, + 195, + 22, + 158, + 166, + 101, + 197, + 10, + 7, + 211, + 140, + 60, + 112, + 229, + 216, + 241, + 45, + 175, + 8, + 74, + 84, + 128, + 166, + 101, + 144, + 197, + 242, + 147, + 80, + 154, + 143, + 63, + 127, + 138, + 131, + 163, + 84, + 213 + ]); + _testSigning( + keyPair, algorithms.signing.ecdsa.sha256, data, signature, true); + }); + + test('Example Signing Using ECDSA P-256K SHA-256', () { + var keyPair = KeyPair.generateEc(curves.p256k); + + var data = Uint8List.fromList('hello world'.codeUnits); + + _testSigning(keyPair, algorithms.signing.ecdsa.sha256, data); + }); + + test('Example Signing Using ECDSA P-521 SHA-512', () { + var jwk = { + 'kty': 'EC', + 'crv': 'P-521', + 'x': 'AekpBQ8ST8a8VcfVOTNl353vSrDCLLJXmPk06wTjxrrjcBpXp5EOnYG_' + 'NjFZ6OvLFV1jSfS9tsz4qUxcWceqwQGk', + 'y': 'ADSmRA43Z1DSNx_RvcLI87cdL07l6jQyyBXMoxVg_l2Th-x3S1WDhjDl' + 'y79ajL4Kkd0AZMaZmh9ubmf63e3kyMj2', + 'd': 'AY5pb7A0UFiB3RELSD64fTLOSV_jazdF7fLYyuTw8lOfRhWg6Y6rUrPA' + 'xerEzgdRhajnu0ferB0d53vM9mE15j2C' + }; + + var keyPair = KeyPair.fromJwk(jwk); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + + var data = Uint8List.fromList([ + 101, + 121, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 70, + 85, + 122, + 85, + 120, + 77, + 105, + 74, + 57, + 46, + 85, + 71, + 70, + 53, + 98, + 71, + 57, + 104, + 90, + 65 + ]); + var signature = Uint8List.fromList([ + 1, + 220, + 12, + 129, + 231, + 171, + 194, + 209, + 232, + 135, + 233, + 117, + 247, + 105, + 122, + 210, + 26, + 125, + 192, + 1, + 217, + 21, + 82, + 91, + 45, + 240, + 255, + 83, + 19, + 34, + 239, + 71, + 48, + 157, + 147, + 152, + 105, + 18, + 53, + 108, + 163, + 214, + 68, + 231, + 62, + 153, + 150, + 106, + 194, + 164, + 246, + 72, + 143, + 138, + 24, + 50, + 129, + 223, + 133, + 206, + 209, + 172, + 63, + 237, + 119, + 109, + 0, + 111, + 6, + 105, + 44, + 5, + 41, + 208, + 128, + 61, + 152, + 40, + 92, + 61, + 152, + 4, + 150, + 66, + 60, + 69, + 247, + 196, + 170, + 81, + 193, + 199, + 78, + 59, + 194, + 169, + 16, + 124, + 9, + 143, + 42, + 142, + 131, + 48, + 206, + 238, + 34, + 175, + 83, + 203, + 220, + 159, + 3, + 107, + 155, + 22, + 27, + 73, + 111, + 68, + 68, + 21, + 238, + 144, + 229, + 232, + 148, + 188, + 222, + 59, + 242, + 103 + ]); + _testSigning( + keyPair, algorithms.signing.ecdsa.sha512, data, signature, true); + }); + + test('Example Signing Using generated EC keys', () { + var text = 'Can we sign and verify this text'; + var data = utf8.encode(text); + + for (var curve in [curves.p256, curves.p384, curves.p521]) { + var keyPair = KeyPair.generateEc(curve); + var alg = { + curves.p256: algorithms.signing.ecdsa.sha256, + curves.p384: algorithms.signing.ecdsa.sha384, + curves.p521: algorithms.signing.ecdsa.sha512, + }[curve]!; + + _testSigning(keyPair, alg, data); + } + }); + + test('Example handling unkown curve', () { + // Ed25519 is supported (RFC 8037), but an unsupported OKP curve such as + // Ed448 must still yield a null KeyPair. + var jwk = { + 'kty': 'OKP', + 'use': 'sig', + 'crv': 'Ed448', + 'kid': 'fd8709f3-df83-4c91-81dd-97d0c4ae3c79', + 'x': 'aigIzKFXi-h-wtqhbUmK3knnbBgd_I9169keewi5Fcc', + }; + + var keyPair = KeyPair.fromJwk(jwk); + expect(keyPair, null); + }); + }); + }); + + group('Encryption', () { + group('Content Encryption with symmetric keys', () { + var data = Uint8List.fromList([ + 76, + 105, + 118, + 101, + 32, + 108, + 111, + 110, + 103, + 32, + 97, + 110, + 100, + 32, + 112, + 114, + 111, + 115, + 112, + 101, + 114, + 46 + ]); + + test('Example encryption using AES_128_CBC', () { + var keyPair = KeyPair.symmetric(SymmetricKey( + keyValue: Uint8List.fromList([ + 107, + 124, + 212, + 45, + 111, + 107, + 9, + 219, + 200, + 177, + 0, + 240, + 143, + 156, + 44, + 207 + ]))); + var encryptedData = EncryptionResult( + Uint8List.fromList([ + 40, + 57, + 83, + 181, + 119, + 33, + 133, + 148, + 198, + 185, + 243, + 24, + 152, + 230, + 6, + 75, + 129, + 223, + 127, + 19, + 210, + 82, + 183, + 230, + 168, + 33, + 215, + 104, + 143, + 112, + 56, + 102 + ]), + initializationVector: Uint8List.fromList([ + 3, + 22, + 60, + 12, + 43, + 67, + 104, + 105, + 108, + 108, + 105, + 99, + 111, + 116, + 104, + 101 + ])); + + _testEncryption( + keyPair, algorithms.encryption.aes.cbc, data, encryptedData, false); + }); + + test('Example encryption using AES_192_CBC', () { + var keyPair = KeyPair.generateSymmetric(192); + _testEncryption(keyPair, algorithms.encryption.aes.cbc, data); + }); + test('Example encryption using AES_256_CBC', () { + var keyPair = KeyPair.generateSymmetric(256); + _testEncryption(keyPair, algorithms.encryption.aes.cbc, data); + }); + + test('Example encryption using AES_128_CBC_HMAC_SHA_256', () { + var keyPair = KeyPair.symmetric(SymmetricKey( + keyValue: Uint8List.fromList([ + 4, + 211, + 31, + 197, + 84, + 157, + 252, + 254, + 11, + 100, + 157, + 250, + 63, + 170, + 106, + 206, + 107, + 124, + 212, + 45, + 111, + 107, + 9, + 219, + 200, + 177, + 0, + 240, + 143, + 156, + 44, + 207 + ]))); + var encryptedData = EncryptionResult( + Uint8List.fromList([ + 40, + 57, + 83, + 181, + 119, + 33, + 133, + 148, + 198, + 185, + 243, + 24, + 152, + 230, + 6, + 75, + 129, + 223, + 127, + 19, + 210, + 82, + 183, + 230, + 168, + 33, + 215, + 104, + 143, + 112, + 56, + 102 + ]), + initializationVector: Uint8List.fromList([ + 3, + 22, + 60, + 12, + 43, + 67, + 104, + 105, + 108, + 108, + 105, + 99, + 111, + 116, + 104, + 101 + ]), + additionalAuthenticatedData: Uint8List.fromList([ + 101, + 121, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 66, + 77, + 84, + 73, + 52, + 83, + 49, + 99, + 105, + 76, + 67, + 74, + 108, + 98, + 109, + 77, + 105, + 79, + 105, + 74, + 66, + 77, + 84, + 73, + 52, + 81, + 48, + 74, + 68, + 76, + 85, + 104, + 84, + 77, + 106, + 85, + 50, + 73, + 110, + 48 + ]), + authenticationTag: Uint8List.fromList([ + 83, + 73, + 191, + 98, + 104, + 205, + 211, + 128, + 201, + 189, + 199, + 133, + 32, + 38, + 194, + 85 + ])); + + _testEncryption(keyPair, algorithms.encryption.aes.cbcWithHmac.sha256, + data, encryptedData, false); + }); + test('Example encryption using AES_192_CBC_HMAC_SHA_384', () { + var keyPair = KeyPair.generateSymmetric(384); + _testEncryption( + keyPair, algorithms.encryption.aes.cbcWithHmac.sha384, data); + }); + test('Example encryption using AES_256_CBC_HMAC_SHA_512', () { + var keyPair = KeyPair.generateSymmetric(512); + _testEncryption( + keyPair, algorithms.encryption.aes.cbcWithHmac.sha512, data); + }); + + test('Example encryption using AES_128_GCM', () { + var keyPair = KeyPair.generateSymmetric(128); + _testEncryption(keyPair, algorithms.encryption.aes.gcm, data); + }); + test('Example encryption using AES_192_GCM', () { + var keyPair = KeyPair.generateSymmetric(192); + _testEncryption(keyPair, algorithms.encryption.aes.gcm, data); + }); + test( + 'Example encryption using AES_256_GCM', + () { + var data = Uint8List.fromList([ + 84, + 104, + 101, + 32, + 116, + 114, + 117, + 101, + 32, + 115, + 105, + 103, + 110, + 32, + 111, + 102, + 32, + 105, + 110, + 116, + 101, + 108, + 108, + 105, + 103, + 101, + 110, + 99, + 101, + 32, + 105, + 115, + 32, + 110, + 111, + 116, + 32, + 107, + 110, + 111, + 119, + 108, + 101, + 100, + 103, + 101, + 32, + 98, + 117, + 116, + 32, + 105, + 109, + 97, + 103, + 105, + 110, + 97, + 116, + 105, + 111, + 110, + 46 + ]); + var keyPair = KeyPair.symmetric(SymmetricKey( + keyValue: Uint8List.fromList([ + 177, + 161, + 244, + 128, + 84, + 143, + 225, + 115, + 63, + 180, + 3, + 255, + 107, + 154, + 212, + 246, + 138, + 7, + 110, + 91, + 112, + 46, + 34, + 105, + 47, + 130, + 203, + 46, + 122, + 234, + 64, + 252 + ]))); + + var encryptedData = EncryptionResult( + Uint8List.fromList([ + 229, + 236, + 166, + 241, + 53, + 191, + 115, + 196, + 174, + 43, + 73, + 109, + 39, + 122, + 233, + 96, + 140, + 206, + 120, + 52, + 51, + 237, + 48, + 11, + 190, + 219, + 186, + 80, + 111, + 104, + 50, + 142, + 47, + 167, + 59, + 61, + 181, + 127, + 196, + 21, + 40, + 82, + 242, + 32, + 123, + 143, + 168, + 226, + 73, + 216, + 176, + 144, + 138, + 247, + 106, + 60, + 16, + 205, + 160, + 109, + 64, + 63, + 192 + ]), + additionalAuthenticatedData: Uint8List.fromList([ + 101, + 121, + 74, + 104, + 98, + 71, + 99, + 105, + 79, + 105, + 74, + 83, + 85, + 48, + 69, + 116, + 84, + 48, + 70, + 70, + 85, + 67, + 73, + 115, + 73, + 109, + 86, + 117, + 89, + 121, + 73, + 54, + 73, + 107, + 69, + 121, + 78, + 84, + 90, + 72, + 81, + 48, + 48, + 105, + 102, + 81 + ]), + initializationVector: Uint8List.fromList( + [227, 197, 117, 252, 2, 219, 233, 68, 180, 225, 77, 219]), + authenticationTag: Uint8List.fromList([ + 92, + 80, + 104, + 49, + 133, + 25, + 161, + 215, + 173, + 101, + 219, + 211, + 136, + 91, + 210, + 145 + ])); + + _testEncryption(keyPair, algorithms.encryption.aes.gcm, data, + encryptedData, false); + }, /* skip: 'GCM mode not implemented'*/ + ); + }); + + group( + 'Key Encryption with symmetric keys', + () { + var data = Uint8List.fromList([ + 4, + 211, + 31, + 197, + 84, + 157, + 252, + 254, + 11, + 100, + 157, + 250, + 63, + 170, + 106, + 206, + 107, + 124, + 212, + 45, + 111, + 107, + 9, + 219, + 200, + 177, + 0, + 240, + 143, + 156, + 44, + 207 + ]); + + test('Example encryption using AES Key Wrap 128', () { + var keyPair = + KeyPair.fromJwk({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + + var encryptedData = EncryptionResult(Uint8List.fromList([ + 232, + 160, + 123, + 211, + 183, + 76, + 245, + 132, + 200, + 128, + 123, + 75, + 190, + 216, + 22, + 67, + 201, + 138, + 193, + 186, + 9, + 91, + 122, + 31, + 246, + 90, + 28, + 139, + 57, + 3, + 76, + 124, + 193, + 11, + 98, + 37, + 173, + 61, + 104, + 57 + ])); + + _testEncryption(keyPair, algorithms.encryption.aes.keyWrap, data, + encryptedData, false); + }); + test('Example encryption using AES Key Wrap 192', () { + var keyPair = KeyPair.generateSymmetric(192); + _testEncryption(keyPair, algorithms.encryption.aes.keyWrap, data); + }); + test('Example encryption using AES Key Wrap 256', () { + var keyPair = KeyPair.generateSymmetric(256); + _testEncryption(keyPair, algorithms.encryption.aes.keyWrap, data); + }); + }, /*skip: 'AES key wrap not implemented'*/ + ); + + group('Encryption with RSA keys', () { + test('Example encryption using RSAES-PKCS1-v1_5', () { + var jwk = { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }; + var keyPair = KeyPair.fromJwk(jwk); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + + var data = Uint8List.fromList([ + 4, + 211, + 31, + 197, + 84, + 157, + 252, + 254, + 11, + 100, + 157, + 250, + 63, + 170, + 106, + 206, + 107, + 124, + 212, + 45, + 111, + 107, + 9, + 219, + 200, + 177, + 0, + 240, + 143, + 156, + 44, + 207 + ]); + + var encryptedData = EncryptionResult(Uint8List.fromList([ + 80, + 104, + 72, + 58, + 11, + 130, + 236, + 139, + 132, + 189, + 255, + 205, + 61, + 86, + 151, + 176, + 99, + 40, + 44, + 233, + 176, + 189, + 205, + 70, + 202, + 169, + 72, + 40, + 226, + 181, + 156, + 223, + 120, + 156, + 115, + 232, + 150, + 209, + 145, + 133, + 104, + 112, + 237, + 156, + 116, + 250, + 65, + 102, + 212, + 210, + 103, + 240, + 177, + 61, + 93, + 40, + 71, + 231, + 223, + 226, + 240, + 157, + 15, + 31, + 150, + 89, + 200, + 215, + 198, + 203, + 108, + 70, + 117, + 66, + 212, + 238, + 193, + 205, + 23, + 161, + 169, + 218, + 243, + 203, + 128, + 214, + 127, + 253, + 215, + 139, + 43, + 17, + 135, + 103, + 179, + 220, + 28, + 2, + 212, + 206, + 131, + 158, + 128, + 66, + 62, + 240, + 78, + 186, + 141, + 125, + 132, + 227, + 60, + 137, + 43, + 31, + 152, + 199, + 54, + 72, + 34, + 212, + 115, + 11, + 152, + 101, + 70, + 42, + 219, + 233, + 142, + 66, + 151, + 250, + 126, + 146, + 141, + 216, + 190, + 73, + 50, + 177, + 146, + 5, + 52, + 247, + 28, + 197, + 21, + 59, + 170, + 247, + 181, + 89, + 131, + 241, + 169, + 182, + 246, + 99, + 15, + 36, + 102, + 166, + 182, + 172, + 197, + 136, + 230, + 120, + 60, + 58, + 219, + 243, + 149, + 94, + 222, + 150, + 154, + 194, + 110, + 227, + 225, + 112, + 39, + 89, + 233, + 112, + 207, + 211, + 241, + 124, + 174, + 69, + 221, + 179, + 107, + 196, + 225, + 127, + 167, + 112, + 226, + 12, + 242, + 16, + 24, + 28, + 120, + 182, + 244, + 213, + 244, + 153, + 194, + 162, + 69, + 160, + 244, + 248, + 63, + 165, + 141, + 4, + 207, + 249, + 193, + 79, + 131, + 0, + 169, + 233, + 127, + 167, + 101, + 151, + 125, + 56, + 112, + 111, + 248, + 29, + 232, + 90, + 29, + 147, + 110, + 169, + 146, + 114, + 165, + 204, + 71, + 136, + 41, + 252 + ])); + + _testEncryption(keyPair, algorithms.encryption.rsa.pkcs1, data, + encryptedData, true); + }); + + test('Example encryption using RSAES-OAEP', () { + var jwk = { + 'kty': 'RSA', + 'n': 'oahUIoWw0K0usKNuOR6H4wkf4oBUXHTxRvgb48E-BVvxkeDNjbC4he8rUW' + 'cJoZmds2h7M70imEVhRU5djINXtqllXI4DFqcI1DgjT9LewND8MW2Krf3S' + 'psk_ZkoFnilakGygTwpZ3uesH-PFABNIUYpOiN15dsQRkgr0vEhxN92i2a' + 'sbOenSZeyaxziK72UwxrrKoExv6kc5twXTq4h-QChLOln0_mtUZwfsRaMS' + 'tPs6mS6XrgxnxbWhojf663tuEQueGC-FCMfra36C9knDFGzKsNa7LZK2dj' + 'YgyD3JR_MB_4NUJW_TqOQtwHYbxevoJArm-L5StowjzGy-_bq6Gw', + 'e': 'AQAB', + 'd': 'kLdtIj6GbDks_ApCSTYQtelcNttlKiOyPzMrXHeI-yk1F7-kpDxY4-WY5N' + 'WV5KntaEeXS1j82E375xxhWMHXyvjYecPT9fpwR_M9gV8n9Hrh2anTpTD9' + '3Dt62ypW3yDsJzBnTnrYu1iwWRgBKrEYY46qAZIrA2xAwnm2X7uGR1hghk' + 'qDp0Vqj3kbSCz1XyfCs6_LehBwtxHIyh8Ripy40p24moOAbgxVw3rxT_vl' + 't3UVe4WO3JkJOzlpUf-KTVI2Ptgm-dARxTEtE-id-4OJr0h-K-VFs3VSnd' + 'VTIznSxfyrj8ILL6MG_Uv8YAu7VILSB3lOW085-4qE3DzgrTjgyQ', + 'p': '1r52Xk46c-LsfB5P442p7atdPUrxQSy4mti_tZI3Mgf2EuFVbUoDBvaRQ-' + 'SWxkbkmoEzL7JXroSBjSrK3YIQgYdMgyAEPTPjXv_hI2_1eTSPVZfzL0lf' + 'fNn03IXqWF5MDFuoUYE0hzb2vhrlN_rKrbfDIwUbTrjjgieRbwC6Cl0', + 'q': 'wLb35x7hmQWZsWJmB_vle87ihgZ19S8lBEROLIsZG4ayZVe9Hi9gDVCOBm' + 'UDdaDYVTSNx_8Fyw1YYa9XGrGnDew00J28cRUoeBB_jKI1oma0Orv1T9aX' + 'IWxKwd4gvxFImOWr3QRL9KEBRzk2RatUBnmDZJTIAfwTs0g68UZHvtc', + 'dp': 'ZK-YwE7diUh0qR1tR7w8WHtolDx3MZ_OTowiFvgfeQ3SiresXjm9gZ5KL' + 'hMXvo-uz-KUJWDxS5pFQ_M0evdo1dKiRTjVw_x4NyqyXPM5nULPkcpU827' + 'rnpZzAJKpdhWAgqrXGKAECQH0Xt4taznjnd_zVpAmZZq60WPMBMfKcuE', + 'dq': 'Dq0gfgJ1DdFGXiLvQEZnuKEN0UUmsJBxkjydc3j4ZYdBiMRAy86x0vHCj' + 'ywcMlYYg4yoC4YZa9hNVcsjqA3FeiL19rk8g6Qn29Tt0cj8qqyFpz9vNDB' + 'UfCAiJVeESOjJDZPYHdHY8v1b-o-Z2X5tvLx-TCekf7oxyeKDUqKWjis', + 'qi': 'VIMpMYbPf47dT1w_zDUXfPimsSegnMOA1zTaX7aGk_8urY6R8-ZW1FxU7' + 'AlWAyLWybqq6t16VFd7hQd0y6flUK4SlOydB61gwanOsXGOAOv82cHq0E3' + 'eL4HrtZkUuKvnPrMnsUUFlfUdybVzxyjz9JF_XyaY14ardLSjf4L_FNY' + }; + + var keyPair = KeyPair.fromJwk(jwk); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + var data = Uint8List.fromList([ + 177, + 161, + 244, + 128, + 84, + 143, + 225, + 115, + 63, + 180, + 3, + 255, + 107, + 154, + 212, + 246, + 138, + 7, + 110, + 91, + 112, + 46, + 34, + 105, + 47, + 130, + 203, + 46, + 122, + 234, + 64, + 252 + ]); + + var encryptedData = EncryptionResult(Uint8List.fromList([ + 56, + 163, + 154, + 192, + 58, + 53, + 222, + 4, + 105, + 218, + 136, + 218, + 29, + 94, + 203, + 22, + 150, + 92, + 129, + 94, + 211, + 232, + 53, + 89, + 41, + 60, + 138, + 56, + 196, + 216, + 82, + 98, + 168, + 76, + 37, + 73, + 70, + 7, + 36, + 8, + 191, + 100, + 136, + 196, + 244, + 220, + 145, + 158, + 138, + 155, + 4, + 117, + 141, + 230, + 199, + 247, + 173, + 45, + 182, + 214, + 74, + 177, + 107, + 211, + 153, + 11, + 205, + 196, + 171, + 226, + 162, + 128, + 171, + 182, + 13, + 237, + 239, + 99, + 193, + 4, + 91, + 219, + 121, + 223, + 107, + 167, + 61, + 119, + 228, + 173, + 156, + 137, + 134, + 200, + 80, + 219, + 74, + 253, + 56, + 185, + 91, + 177, + 34, + 158, + 89, + 154, + 205, + 96, + 55, + 18, + 138, + 43, + 96, + 218, + 215, + 128, + 124, + 75, + 138, + 243, + 85, + 25, + 109, + 117, + 140, + 26, + 155, + 249, + 67, + 167, + 149, + 231, + 100, + 6, + 41, + 65, + 214, + 251, + 232, + 87, + 72, + 40, + 182, + 149, + 154, + 168, + 31, + 193, + 126, + 215, + 89, + 28, + 111, + 219, + 125, + 182, + 139, + 235, + 195, + 197, + 23, + 234, + 55, + 58, + 63, + 180, + 68, + 202, + 206, + 149, + 75, + 205, + 248, + 176, + 67, + 39, + 178, + 60, + 98, + 193, + 32, + 238, + 122, + 96, + 158, + 222, + 57, + 183, + 111, + 210, + 55, + 188, + 215, + 206, + 180, + 166, + 150, + 166, + 106, + 250, + 55, + 229, + 72, + 40, + 69, + 214, + 216, + 104, + 23, + 40, + 135, + 212, + 28, + 127, + 41, + 80, + 175, + 174, + 168, + 115, + 171, + 197, + 89, + 116, + 92, + 103, + 246, + 83, + 216, + 182, + 176, + 84, + 37, + 147, + 35, + 45, + 219, + 172, + 99, + 226, + 233, + 73, + 37, + 124, + 42, + 72, + 49, + 242, + 35, + 127, + 184, + 134, + 117, + 114, + 135, + 206 + ])); + + /// OAEP doesn't support decryption from a PrivateKey in PointyCastle. + _testEncryption( + keyPair, + algorithms.encryption.rsa.oaep, + data, + encryptedData, + true, + false, + ); + }); + + test('Example encryption using RSAES-OAEP-256', () { + var jwk = { + 'kty': 'RSA', + 'n': '2cQJH1f6yF9DcGa8Cmbnhn4LHLs5L6kNb2rxkrNFZArJLRaKvaC3tMCKZ8Zg' + 'IpO9bVMPx5UMjJoaf7p9O5BSApVqA2J10fUbdSIomCcDwvGo0eyhty0D' + 'ILLWTMXzGEVM3BXzuJQoeDkuUCXXcCwA4Msyyd2OHVu-pB2OrGv6fcjH' + 'wjINty3UoKm08lCvAevBKHsuA-FFwQII9bycvRx5wRqFUjdMAyiOmLYB' + 'HBaJSi11g3HVexMcb29v14PSlVzdGUMN8oboa-zcIyaPrIiczLqAkSXQ' + 'NdEFHrjsJHfFeNMfOblLM7icKN_tyWujYeItt4kqUIimPn5dHjwgcQYE7w', + 'e': 'AQAB', + 'd': 'dyUz3ItVceX1Tv1WqtZMnKA_0jN5gWMcL7ayf5JISAlCssGfnUre2C10TH0U' + 'QjbVMIh-nLMnD5KNJw9Qz5MR28oGG932Gq7hm__ZeA34l-OCe4Ddpgwh' + 'pvVSHOU9MS1RdSUpmPavAcA_X6ikrAHXZSaoHhxzUgrNTpvBYQMfJUv_' + '492fStIseQ9rwAMOpCWOiWMZOQm3KJVTLLunXdKf_UxmzmKXYKYZWke3' + 'AWIzUqnOfqIjfDTMunF4UWU0zKlhcsaQNmYMVrJGajD1bJdy_dbUU3LE' + '8sx-bdkUI6oBk-sFtTTVyVdQcetG9kChJ5EnY5R6tt_4_xFG5kxzTo6qaQ', + 'p': '7yQmgE60SL7QrXpAJhChLgKnXWi6C8tVx1lA8FTpphpLaCtK-HbgBVHC' + 'prC2CfaM1mxFJZahxgFjC9ehuV8OzMNyFs8kekS82EsQGksi8HJP' + 'xyR1fU6ATa36ogPG0nNaqm3EDmYyjowhntgBz2OkbFAsTMHTdna-' + 'pZBRJa9lm5U', + 'q': '6R4dzo9LwHLO73EMQPQsmwXjVOvAS5W6rgQ-BCtMhec_QosAXIVE' + '3AGyfweqZm6rurXCVFykDLwJ30GepLQ8nTlzeV6clx0x70sa' + 'GGKKVmCsHuVYWwgIRyJTrt4SX29NQDZ_FE52NlO3OhPkj1Ex' + 'Sk_pGMqGRFd26K8g0jJsXXM', + 'dp': 'VByn-hs0qB2Ncmb8ZycUOgWu7ljmjz1up1ZKU_3ZzJWVDkej7-6H' + '7vcJ-u1OqgRxFv4v9_-aWPWl68VlWbkIkJbx6vniv6qrrXwB' + 'Zu4klOPwEYBOXsucrzXRYOjpJp5yNl2zRslFYQQC00bwpAxN' + 'CdfNLRZDlXhAqCUxlYqyt10', + 'dq': 'MJFbuGtWZvQEdRJicS3uFSY25LxxRc4eJJ8xpIC44rT5Ew4O' + 'tzf0zrlzzM92Cv1HvhCcOiNK8nRCwkbTnJEIh-EuU70I' + 'dttYSfilqSruk2x0r8Msk1qrDtbyBF60CToRKC2ycDKg' + 'olTyuaDnX4yU7lyTvdyD-L0YQwYpmmFy_k0', + 'qi': 'vy7XCwZ3jyMGik81TIZDAOQKC8FVUc0TG5KVYfti4tgw' + 'zUqFwtuB8Oc1ctCKRbE7uZUPwZh4OsCTLqIvqBQd' + 'a_kaxOxo5EF7iXj6yHmZ2s8P_Z_u3JLuh-oAT_6k' + 'mbLx6CAO0DbtKtxp24Ivc1hDfqSwWORgN1AOrSRC' + 'mE3nwxg' + }; + + var keyPair = KeyPair.fromJwk(jwk); + + if (keyPair == null) { + throw UnimplementedError('Unkown keyPair'); + } + + var encryptedData = EncryptionResult(Uint8List.fromList([ + 124, + 190, + 72, + 47, + 151, + 12, + 10, + 56, + 212, + 244, + 111, + 127, + 102, + 59, + 3, + 217, + 115, + 180, + 28, + 140, + 19, + 59, + 6, + 213, + 194, + 233, + 92, + 101, + 92, + 55, + 215, + 250, + 241, + 218, + 164, + 220, + 31, + 54, + 216, + 132, + 139, + 235, + 189, + 183, + 21, + 45, + 55, + 201, + 206, + 127, + 49, + 109, + 13, + 212, + 16, + 190, + 56, + 217, + 152, + 25, + 212, + 247, + 5, + 96, + 161, + 13, + 97, + 227, + 215, + 136, + 76, + 175, + 99, + 19, + 205, + 241, + 217, + 175, + 143, + 243, + 159, + 45, + 34, + 67, + 18, + 164, + 122, + 146, + 252, + 14, + 111, + 169, + 224, + 132, + 107, + 113, + 127, + 55, + 65, + 253, + 84, + 111, + 22, + 221, + 158, + 122, + 22, + 194, + 3, + 120, + 22, + 166, + 35, + 152, + 28, + 123, + 210, + 76, + 87, + 65, + 60, + 108, + 118, + 41, + 145, + 208, + 138, + 194, + 243, + 222, + 134, + 81, + 240, + 69, + 162, + 103, + 92, + 40, + 18, + 145, + 242, + 241, + 174, + 197, + 161, + 13, + 179, + 206, + 224, + 229, + 100, + 66, + 80, + 57, + 74, + 225, + 211, + 214, + 255, + 16, + 156, + 102, + 112, + 77, + 163, + 47, + 172, + 38, + 18, + 216, + 26, + 111, + 44, + 114, + 221, + 88, + 22, + 131, + 113, + 170, + 72, + 245, + 8, + 74, + 82, + 89, + 87, + 209, + 211, + 134, + 229, + 73, + 123, + 60, + 228, + 180, + 151, + 105, + 158, + 212, + 67, + 156, + 3, + 212, + 170, + 55, + 60, + 35, + 0, + 114, + 26, + 147, + 37, + 120, + 132, + 28, + 62, + 224, + 84, + 177, + 228, + 81, + 62, + 191, + 206, + 178, + 186, + 14, + 39, + 167, + 147, + 151, + 144, + 238, + 20, + 41, + 129, + 56, + 166, + 254, + 120, + 211, + 112, + 230, + 255, + 134, + 17, + 197, + 88, + 175, + 92, + 59, + 95, + 218, + 23, + 160 + ])); + + var data = + base64Url.decode('A0lSKnZfXznJcsx6nRs78tJw9EqXfaCn-tbsMxvITdA='); + + _testEncryption(keyPair, algorithms.encryption.rsa.oaep256, data, + encryptedData, true, false); + }); + }); + }); +} + +List base64ToBytes(String encoded) { + encoded += List.filled((4 - encoded.length % 4) % 4, '=').join(); + return base64Url.decode(encoded); +} + +BigInt base64ToInt(String encoded) { + final b256 = BigInt.from(256); + return base64ToBytes(encoded) + .fold(BigInt.zero, (a, b) => a * b256 + BigInt.from(b)); +} diff --git a/packages/crypto_keys_plus/test/library_surface_test.dart b/packages/crypto_keys_plus/test/library_surface_test.dart new file mode 100644 index 00000000..6f841dd8 --- /dev/null +++ b/packages/crypto_keys_plus/test/library_surface_test.dart @@ -0,0 +1,25 @@ +// Ensures the package-name-matching entrypoint (`crypto_keys_plus.dart`) is +// actually loaded by the test suite, so its re-exported surface stays +// visible to coverage tooling even though the rest of the suite only ever +// imports the legacy `crypto_keys.dart` entrypoint. +import 'package:crypto_keys_plus/crypto_keys_plus.dart'; +import 'package:test/test.dart'; + +void main() { + group('crypto_keys_plus library surface', () { + test( + 'algorithms exposes the documented digest identifiers', + () { + // `algorithms` is the top-level singleton re-exported from + // `crypto_keys.dart` via the package-name-matching barrel. Its + // identifier names are part of the documented public contract. + expect(algorithms.digest.sha256.name, 'digest/SHA-256'); + expect(algorithms.digest.sha512.name, 'digest/SHA-512'); + }, + ); + + test('curves exposes the documented P-256 curve identifier', () { + expect(curves.p256.name, 'curve/P-256'); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/pointycastle_ext_test.dart b/packages/crypto_keys_plus/test/pointycastle_ext_test.dart new file mode 100644 index 00000000..0adec52c --- /dev/null +++ b/packages/crypto_keys_plus/test/pointycastle_ext_test.dart @@ -0,0 +1,168 @@ +// Targeted tests for `lib/src/pointycastle_ext.dart`. This file is combined +// (not `part of`) into the main `crypto_keys.dart` library and its classes +// are not re-exported from the package's public entrypoints, so it must be +// imported directly by its `src/` path to reach the branches below. +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/src/pointycastle_ext.dart'; +import 'package:pointycastle/export.dart' as pc; +import 'package:test/test.dart'; + +/// A minimal concrete [BlockCipherWithAuthenticationTag] that does NOT +/// override [BlockCipherWithAuthenticationTag.processBlocks], so calling +/// [BlockCipherWithAuthenticationTag.process] exercises the base-class +/// `processBlocks` loop directly (the only production subclass, +/// `AesCbcAuthenticatedCipherWithHash`, overrides it). +class _IdentityTaggedCipher extends BlockCipherWithAuthenticationTag { + @override + String get algorithmName => 'identity-tagged'; + + @override + int get blockSize => 4; + + @override + int get tagLength => 2; + + @override + void initParameters(pc.CipherParameters? parameters) {} + + @override + Uint8List finalizeTag() => Uint8List.fromList([0xaa, 0xbb]); + + @override + int processBlock(Uint8List inp, int inpOff, Uint8List out, int outOff) { + // Identity transform: copy up to `blockSize` bytes (or whatever remains) + // from `inp` at `inpOff` into `out` at `outOff`. + var n = blockSize; + if (inpOff + n > inp.length) { + n = inp.length - inpOff; + } + for (var i = 0; i < n; i++) { + out[outOff + i] = inp[inpOff + i]; + } + return blockSize; + } + + @override + void reset() {} +} + +void main() { + group('toHex', () { + test('encodes bytes as lowercase two-digit hex, zero-padded', () { + expect(toHex([0, 255, 16, 1]), '00ff1001'); + }); + + test('empty input yields empty string', () { + expect(toHex(const []), ''); + }); + }); + + group('BlockCipherWithAuthenticationTag base processBlocks', () { + test('encrypting: appends the finalized tag after the ciphertext', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + true, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final out = cipher.process(Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8])); + // 8 bytes of (identity) ciphertext + the 2-byte tag from finalizeTag(). + expect(out, [1, 2, 3, 4, 5, 6, 7, 8, 0xaa, 0xbb]); + }); + + test('decrypting: strips and verifies the trailing tag', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + false, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final combined = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 0xaa, 0xbb]); + expect(cipher.process(combined), [1, 2, 3, 4, 5, 6, 7, 8]); + }); + + test('decrypting: throws when the trailing tag does not match', () { + final cipher = _IdentityTaggedCipher(); + cipher.init( + false, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final tampered = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 0x00, 0x00]); + expect(() => cipher.process(tampered), throwsA(anything)); + }); + + test( + 'processBlocks pads the final partial block by reading past the ' + 'declared length', () { + // Exercises the `inputBlocks` ceil-division branch: 5 bytes over a + // block size of 4 requires 2 iterations. + final cipher = _IdentityTaggedCipher(); + cipher.init( + true, + ParametersWithIVAndAad( + pc.KeyParameter(Uint8List(16)), + Uint8List(12), + Uint8List(0), + ), + ); + final out = cipher.process(Uint8List.fromList([9, 8, 7, 6, 5])); + expect(out.sublist(0, 5), [9, 8, 7, 6, 5]); + expect(out.sublist(5), [0xaa, 0xbb]); + }); + }); + + group('AesCbcAuthenticatedCipherWithHash', () { + test('algorithmName combines the underlying cipher and MAC names', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(cipher.algorithmName, 'AES/CBC/PKCS7+SHA-256/HMAC'); + }); + + test('blockSize matches the underlying AES-CBC block size', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(cipher.blockSize, 16); + }); + + test('processBlock is defensively unimplemented', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect( + () => cipher.processBlock(Uint8List(16), 0, Uint8List(16), 0), + throwsUnsupportedError, + ); + }); + + test('reset is defensively unimplemented', () { + final cipher = + AesCbcAuthenticatedCipherWithHash(pc.HMac(pc.SHA256Digest(), 64)); + expect(() => cipher.reset(), throwsUnsupportedError); + }); + }); + + group('AESKeyWrap', () { + test('processBlock is defensively unimplemented', () { + final wrap = AESKeyWrap(); + expect( + () => wrap.processBlock(Uint8List(16), 0, Uint8List(16), 0), + throwsUnsupportedError, + ); + }); + + test('reset is defensively unimplemented', () { + final wrap = AESKeyWrap(); + expect(() => wrap.reset(), throwsUnsupportedError); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/pss_eddsa_test.dart b/packages/crypto_keys_plus/test/pss_eddsa_test.dart new file mode 100644 index 00000000..448f1c54 --- /dev/null +++ b/packages/crypto_keys_plus/test/pss_eddsa_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:test/test.dart'; + +Uint8List _b64url(String s) { + return Uint8List.fromList( + base64Url.decode(s + List.filled((4 - s.length % 4) % 4, '=').join())); +} + +void main() { + group('RSASSA-PSS', () { + // A single 2048-bit RSA key reused across the PSS algorithms. + final rsa = KeyPair.generateRsa(bitStrength: 2048); + final message = + Uint8List.fromList(utf8.encode('the quick brown fox jumps over')); + + for (final entry in { + 'PS256': algorithms.signing.rsa.pss.sha256, + 'PS384': algorithms.signing.rsa.pss.sha384, + 'PS512': algorithms.signing.rsa.pss.sha512, + }.entries) { + group(entry.key, () { + final alg = entry.value; + + test('sign/verify round trip', () { + final signer = rsa.createSigner(alg); + final signature = signer.sign(message); + + final verifier = rsa.createVerifier(alg); + expect(verifier.verify(message, signature), isTrue); + }); + + test('is non-deterministic (random salt)', () { + final signer = rsa.createSigner(alg); + final a = signer.sign(message).data; + final b = signer.sign(message).data; + // Different salt => different signature, but both verify. + expect(a, isNot(equals(b))); + final verifier = rsa.createVerifier(alg); + expect(verifier.verify(message, Signature(a)), isTrue); + expect(verifier.verify(message, Signature(b)), isTrue); + }); + + test('rejects a flipped signature byte', () { + final signer = rsa.createSigner(alg); + final signature = signer.sign(message).data; + final tampered = Uint8List.fromList(signature); + tampered[tampered.length ~/ 2] ^= 0x01; + + final verifier = rsa.createVerifier(alg); + expect(verifier.verify(message, Signature(tampered)), isFalse); + }); + + test('rejects a different message', () { + final signer = rsa.createSigner(alg); + final signature = signer.sign(message); + + final verifier = rsa.createVerifier(alg); + final other = Uint8List.fromList(utf8.encode('a different message')); + expect(verifier.verify(other, signature), isFalse); + }); + }); + } + }); + + group('EdDSA / Ed25519 (RFC 8037 Appendix A)', () { + // RFC 8037 Appendix A.1 / A.4 test vector. + final d = _b64url('nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A'); + final x = _b64url('11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo'); + // The JWS Signing Input from Appendix A.4. + final signingInput = Uint8List.fromList(ascii + .encode('eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc')); + final expectedSignature = _b64url( + 'hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5Bh' + 'VsPt9g7sVvpAr_MuM0KAg'); + + final alg = algorithms.signing.eddsa.ed25519; + + test('JWK round-trips through KeyPair.fromJwk', () { + final kp = KeyPair.fromJwk({ + 'kty': 'OKP', + 'crv': 'Ed25519', + 'd': 'nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A', + 'x': '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo', + })!; + expect((kp.publicKey as OkpPublicKey).rawBytes, equals(x)); + expect((kp.privateKey as OkpPrivateKey).rawBytes, equals(d)); + expect((kp.publicKey as OkpPublicKey).curve, equals(curves.ed25519)); + }); + + test('signing produces the RFC 8037 known signature (deterministic)', () { + final priv = OkpPrivateKey(rawBytes: d, curve: curves.ed25519); + final signature = priv.createSigner(alg).sign(signingInput); + expect(signature.data, equals(expectedSignature)); + }); + + test('verifies the RFC 8037 known signature', () { + final pub = OkpPublicKey(rawBytes: x, curve: curves.ed25519); + expect( + pub + .createVerifier(alg) + .verify(signingInput, Signature(expectedSignature)), + isTrue, + ); + }); + + test('rejects a flipped signature byte', () { + final pub = OkpPublicKey(rawBytes: x, curve: curves.ed25519); + final tampered = Uint8List.fromList(expectedSignature); + tampered[0] ^= 0x01; + expect( + pub.createVerifier(alg).verify(signingInput, Signature(tampered)), + isFalse, + ); + }); + + test('rejects a different message', () { + final pub = OkpPublicKey(rawBytes: x, curve: curves.ed25519); + final other = Uint8List.fromList(ascii.encode('not the signed message')); + expect( + pub.createVerifier(alg).verify(other, Signature(expectedSignature)), + isFalse, + ); + }); + + test('generate + sign + verify round trip', () { + final kp = KeyPair.generateEd25519(); + final msg = Uint8List.fromList(utf8.encode('hello ed25519')); + final sig = kp.createSigner(alg).sign(msg); + expect(kp.createVerifier(alg).verify(msg, sig), isTrue); + + final tampered = Uint8List.fromList(msg)..[0] ^= 0x01; + expect(kp.createVerifier(alg).verify(tampered, sig), isFalse); + }); + }); +} diff --git a/packages/crypto_keys_plus/test/tink_algorithm_test.dart b/packages/crypto_keys_plus/test/tink_algorithm_test.dart new file mode 100644 index 00000000..febc17e9 --- /dev/null +++ b/packages/crypto_keys_plus/test/tink_algorithm_test.dart @@ -0,0 +1,456 @@ +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:meta/meta.dart'; +import 'package:test/test.dart'; + +void main() { + group('AEAD', () { + group('AES-GCM', () { + @isTest + void doTest(String name, String keyValue, String plaintext, String aad, + String iv, String ciphertext, String tag) { + test(name, () { + var key = SymmetricKey(keyValue: bytesFromHexString(keyValue)); + + var encrypter = key.createEncrypter(algorithms.encryption.aes.gcm); + + var v = encrypter.decrypt(EncryptionResult( + bytesFromHexString(ciphertext), + initializationVector: bytesFromHexString(iv), + authenticationTag: bytesFromHexString(tag), + additionalAuthenticatedData: bytesFromHexString(aad))); + + expect(v, bytesFromHexString(plaintext)); + }); + } + + doTest('Test Case 1', '00000000000000000000000000000000', '', '', + '000000000000000000000000', '', '58e2fccefa7e3061367f1d57a4e7455a'); + doTest( + 'Test Case 2', + '00000000000000000000000000000000', + '00000000000000000000000000000000', + '', + '000000000000000000000000', + '0388dace60b6a392f328c2b971b2fe78', + 'ab6e47d42cec13bdf53a67b21257bddf'); + doTest( + 'Test Case 3', + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b391aafd255', + '', + 'cafebabefacedbaddecaf888', + '42831ec2217774244b7221b784d0d49c' + 'e3aa212f2c02a4e035c17e2329aca12e' + '21d514b25466931c7d8f6a5aac84aa05' + '1ba30b396a0aac973d58e091473f5985', + '4d5c2af327cd64a62cf35abd2ba6fab4'); + doTest( + 'Test Case 4', + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + 'cafebabefacedbaddecaf888', + '42831ec2217774244b7221b784d0d49c' + 'e3aa212f2c02a4e035c17e2329aca12e' + '21d514b25466931c7d8f6a5aac84aa05' + '1ba30b396a0aac973d58e091', + '5bc94fbc3221a5db94fae95ae7121a47'); + doTest( + 'Test Case 5', + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + 'cafebabefacedbad', + '61353b4c2806934a777ff51fa22a4755' + '699b2a714fcdc6f83766e5f97b6c7423' + '73806900e49f24b22b097544d4896b42' + '4989b5e1ebac0f07c23f4598', + '3612d2e79e3b0785561be14aaca2fccb'); + doTest( + 'Test Case 6', + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + '9313225df88406e555909c5aff5269aa' + '6a7a9538534f7da1e4c303d2a318a728' + 'c3c0c95156809539fcf0e2429a6b5254' + '16aedbf5a0de6a57a637b39b', + '8ce24998625615b603a033aca13fb894' + 'be9112a5c3a211a8ba262a3cca7e2ca7' + '01e4a9a4fba43c90ccdcb281d48c7c6f' + 'd62875d2aca417034c34aee5', + '619cc5aefffe0bfa462af43c1699d050'); + doTest( + 'Test Case 13', + '00000000000000000000000000000000' + '00000000000000000000000000000000', + '', + '', + '000000000000000000000000', + '', + '530f8afbc74536b9a963b4f1c4cb738b'); + doTest( + 'Test Case 14', + '00000000000000000000000000000000' + '00000000000000000000000000000000', + '00000000000000000000000000000000', + '', + '000000000000000000000000', + 'cea7403d4d606b6e074ec5d3baf39d18', + 'd0d1c8a799996bf0265b98b5d48ab919'); + doTest( + 'Test Case 15', + 'feffe9928665731c6d6a8f9467308308' + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b391aafd255', + '', + 'cafebabefacedbaddecaf888', + '522dc1f099567d07f47f37a32a84427d' + '643a8cdcbfe5c0c97598a2bd2555d1aa' + '8cb08e48590dbb3da7b08b1056828838' + 'c5f61e6393ba7a0abcc9f662898015ad', + 'b094dac5d93471bdec1a502270e3cc6c'); + doTest( + 'Test Case 16', + 'feffe9928665731c6d6a8f9467308308' + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + 'cafebabefacedbaddecaf888', + '522dc1f099567d07f47f37a32a84427d' + '643a8cdcbfe5c0c97598a2bd2555d1aa' + '8cb08e48590dbb3da7b08b1056828838' + 'c5f61e6393ba7a0abcc9f662', + '76fc6ece0f4e1768cddf8853bb2d551b'); + doTest( + 'Test Case 17', + 'feffe9928665731c6d6a8f9467308308' + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + 'cafebabefacedbad', + 'c3762df1ca787d32ae47c13bf19844cb' + 'af1ae14d0b976afac52ff7d79bba9de0' + 'feb582d33934a4f0954cc2363bc73f78' + '62ac430e64abe499f47c9b1f', + '3a337dbf46a792c45e454913fe2ea8f2'); + doTest( + 'Test Case 18', + 'feffe9928665731c6d6a8f9467308308' + 'feffe9928665731c6d6a8f9467308308', + 'd9313225f88406e5a55909c5aff5269a' + '86a7a9531534f7da2e4c303d8a318a72' + '1c3c0c95956809532fcf0e2449a6b525' + 'b16aedf5aa0de657ba637b39', + 'feedfacedeadbeeffeedfacedeadbeefabaddad2', + '9313225df88406e555909c5aff5269aa' + '6a7a9538534f7da1e4c303d2a318a728' + 'c3c0c95156809539fcf0e2429a6b5254' + '16aedbf5a0de6a57a637b39b', + '5a8def2f0c9e53f1f75d7853659e2a20' + 'eeb2b22aafde6419a058ab4f6f746bf4' + '0fc0c3b780f244452da3ebf1c5d82cde' + 'a2418997200ef82e44ae7e3f', + 'a44a8266ee1c8eb0c8b5d4cf5ae9f19a'); + }); + + group('AES-EAX', () { + @isTest + void doTest(String name, String keyValue, String plaintext, String aad, + String iv, String ciphertext, String tag) { + test(name, () { + var key = SymmetricKey(keyValue: bytesFromHexString(keyValue)); + + var encrypter = key.createEncrypter(algorithms.encryption.aes.eax); + + var v = encrypter.decrypt(EncryptionResult( + bytesFromHexString(ciphertext), + initializationVector: bytesFromHexString(iv), + authenticationTag: bytesFromHexString(tag), + additionalAuthenticatedData: bytesFromHexString(aad))); + + expect(v, bytesFromHexString(plaintext)); + }); + } + + doTest( + 'Test Case 1', + '233952dee4d5ed5f9b9c6d6ff80ff478', + '', + '6bfb914fd07eae6b', + '62ec67f9c3a4a407fcb2a8c49031a8b3', + '', + 'e037830e8389f27b025a2d6527e79d01'); + doTest( + 'Test Case 2', + '91945d3f4dcbee0bf45ef52255f095a4', + 'f7fb', + 'fa3bfd4806eb53fa', + 'becaf043b0a23d843194ba972c66debd', + '19dd', + '5c4c9331049d0bdab0277408f67967e5'); + doTest( + 'Test Case 3', + '01f74ad64077f2e704c0f60ada3dd523', + '1a47cb4933', + '234a3463c1264ac6', + '70c3db4f0d26368400a10ed05d2bff5e', + 'd851d5bae0', + '3a59f238a23e39199dc9266626c40f80'); + doTest( + 'Test Case 4', + 'd07cf6cbb7f313bdde66b727afd3c5e8', + '481c9e39b1', + '33cce2eabff5a79d', + '8408dfff3c1a2b1292dc199e46b7d617', + '632a9d131a', + 'd4c168a4225d8e1ff755939974a7bede'); + doTest( + 'Test Case 5', + '35b6d0580005bbc12b0587124557d2c2', + '40d0c07da5e4', + 'aeb96eaebe2970e9', + 'fdb6b06676eedc5c61d74276e1f8e816', + '071dfe16c675', + 'cb0677e536f73afe6a14b74ee49844dd'); + doTest( + 'Test Case 6', + 'bd8e6e11475e60b268784c38c62feb22', + '4de3b35c3fc039245bd1fb7d', + 'd4482d1ca78dce0f', + '6eac5c93072d8e8513f750935e46da1b', + '835bb4f15d743e350e728414', + 'abb8644fd6ccb86947c5e10590210a4f'); + doTest( + 'Test Case 7', + '7c77d6e813bed5ac98baa417477a2e7d', + '8b0a79306c9ce7ed99dae4f87f8dd61636', + '65d2017990d62528', + '1a8c98dcd73d38393b2bf1569deefc19', + '02083e3979da014812f59f11d52630da30', + '137327d10649b0aa6e1c181db617d7f2'); + doTest( + 'Test Case 8', + '5fff20cafab119ca2fc73549e20f5b0d', + '1bda122bce8a8dbaf1877d962b8592dd2d56', + '54b9f04e6a09189a', + 'dde59b97d722156d4d9aff2bc7559826', + '2ec47b2c4954a489afc7ba4897edcdae8cc3', + '3b60450599bd02c96382902aef7f832a'); + doTest( + 'Test Case 9', + 'a4a4782bcffd3ec5e7ef6d8c34a56123', + '6cf36720872b8513f6eab1a8a44438d5ef11', + '899a175897561d7e', + 'b781fcf2f75fa5a8de97a9ca48e522ec', + '0de18fd0fdd91e7af19f1d8ee8733938b1e8', + 'e7f6d2231618102fdb7fe55ff1991700'); + doTest( + 'Test Case 10', + '8395fcf1e95bebd697bd010bc766aac3', + 'ca40d7446e545ffaed3bd12a740a659ffbbb3ceab7', + '126735fcc320d25a', + '22e7add93cfc6393c57ec0b3c17d6b44', + 'cb8920f87a6c75cff39627b56e3ed197c552d295a7', + 'cfc46afc253b4652b1af3795b124ab6e'); + }, skip: 'AES-EAX not implemented'); + }); + + group('Digital Signatures', () { + group('ECDSA', () { + @isTest + void doTest( + String name, + String message, + String xCoordinate, + String yCoordinate, + String signature, + AlgorithmIdentifier algorithm, + Identifier curve) { + test(name, () { + var key = EcPublicKey( + xCoordinate: bigIntFromHexString(xCoordinate), + yCoordinate: bigIntFromHexString(yCoordinate), + curve: curve); + + var verifier = key.createVerifier(algorithm); + verifier.verify(bytesFromHexString(message), + Signature(bytesFromHexString(signature))); + }); + } + + doTest( + 'Test case 1', + '60FED4BA255A9D31C961EB74C6356D68C049B8923B61FA6CE669622E60F29FB6', + '7903FE1008B8BC99A41AE9E95628BC64F2F1B20C2D7E9F5177A3C294D4462299', + 'EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716', + 'F7CB1C942D657C41D436C7A1B6E29F65F3E900DBB9AFF4064DC4AB2F843ACDA8', + algorithms.signing.ecdsa.sha256, + curves.p256); + doTest( + 'Test case 2', + 'EC3A4E415B4E19A4568618029F427FA5DA9A8BC4AE92E02E06AAE5286B300C64' + 'DEF8F0EA9055866064A254515480BC13', + '8015D9B72D7D57244EA8EF9AC0C621896708A59367F9DFB9F54CA84B3F1C9DB1' + '288B231C3AE0D4FE7344FD2533264720', + 'ED0959D5880AB2D869AE7F6C2915C6D60F96507F9CB3E047C0046861DA4A799C' + 'FE30F35CC900056D7C99CD7882433709', + '512C8CCEEE3890A84058CE1E22DBC2198F42323CE8ACA9135329F03C068E5112' + 'DC7CC3EF3446DEFCEB01A45C2667FDD5', + algorithms.signing.ecdsa.sha512, + curves.p384); + doTest( + 'Test case 3', + '01894550D0785932E00EAA23B694F213F8C3121F86DC97A04E5A7167DB4E5BCD3' + '71123D46E45DB6B5D5370A7F20FB633155D38FFA16D2BD761DCAC474B9A2F502' + '3A4', + '00493101C962CD4D2FDDF782285E64584139C2F91B47F87FF82354D6630F746A2' + '8A0DB25741B5B34A828008B22ACC23F924FAAFBD4D33F81EA66956DFEAA2BFDF' + 'CF5', + '013E99020ABF5CEE7525D16B69B229652AB6BDF2AFFCAEF38773B4B7D08725F10' + 'CDB93482FDCC54EDCEE91ECA4166B2A7C6265EF0CE2BD7051B7CEF945BABD47E' + 'E6D', + '01FBD0013C674AA79CB39849527916CE301C66EA7CE8B80682786AD60F98F7E78' + 'A19CA69EFF5C57400E3B3A0AD66CE0978214D13BAF4E9AC60752F7B155E2DE4D' + 'CE3', + algorithms.signing.ecdsa.sha512, + curves.p521); + }); + + group('RSA-SSA-PKCS1', () { + @isTest + void doTest(String name, String modulus, String exponent, String message, + String signature, AlgorithmIdentifier algorithm) { + test(name, () { + var key = RsaPublicKey( + exponent: bigIntFromHexString(exponent), + modulus: bigIntFromHexString(modulus), + ); + + var verifier = key.createVerifier(algorithm); + verifier.verify(bytesFromHexString(message), + Signature(bytesFromHexString(signature))); + }); + } + + doTest( + 'Test case 1', + 'c47abacc2a84d56f3614d92fd62ed36ddde459664b9301dcd1d61781cfcc026bcb2399bee7e75681a80b7bf500e2d08ceae1c42ec0b707927f2b2fe92ae852087d25f1d260cc74905ee5f9b254ed05494a9fe06732c3680992dd6f0dc634568d11542a705f83ae96d2a49763d5fbb24398edf3702bc94bc168190166492b8671de874bb9cecb058c6c8344aa8c93754d6effcd44a41ed7de0a9dcd9144437f212b18881d042d331a4618a9e630ef9bb66305e4fdf8f0391b3b2313fe549f0189ff968b92f33c266a4bc2cffc897d1937eeb9e406f5d0eaa7a14782e76af3fce98f54ed237b4a04a4159a5f6250a296a902880204e61d891c4da29f2d65f34cbb', + '49d2a1', + '95123c8d1b236540b86976a11cea31f8bd4e6c54c235147d20ce722b03a6ad756fbd918c27df8ea9ce3104444c0bbe877305bc02e35535a02a58dcda306e632ad30b3dc3ce0ba97fdf46ec192965dd9cd7f4a71b02b8cba3d442646eeec4af590824ca98d74fbca934d0b6867aa1991f3040b707e806de6e66b5934f05509bea', + '51265d96f11ab338762891cb29bf3f1d2b3305107063f5f3245af376dfcc7027d39365de70a31db05e9e10eb6148cb7f6425f0c93c4fb0e2291adbd22c77656afc196858a11e1c670d9eeb592613e69eb4f3aa501730743ac4464486c7ae68fd509e896f63884e9424f69c1c5397959f1e52a368667a598a1fc90125273d9341295d2f8e1cc4969bf228c860e07a3546be2eeda1cde48ee94d062801fe666e4a7ae8cb9cd79262c017b081af874ff00453ca43e34efdb43fffb0bb42a4e2d32a5e5cc9e8546a221fe930250e5f5333e0efe58ffebf19369a3b8ae5a67f6a048bc9ef915bda25160729b508667ada84a0c27e7e26cf2abca413e5e4693f4a9405', + algorithms.signing.rsa.sha256); + doTest( + 'Test case 2', + '9689eb163a617c0abbf01ddc0e6d88c37f8a6b0baec0f6cab8f8a683f372a53d028253a6ba502da462adaf4fd87c8dc2b03b6c07c2b6aacab1d8c8bd043d89f4effe72ea2547c73c6366a2efab9c916945820fb880890bc085564e57ee76f7107a008f71e941e9fd631aec78f82e410ea9c893faa3d553cd1ca628af1087ca1b0c6aef3b66edcee14d1d7dc48293ddd7deed1ccbe487c957585abb9509151038d53f46b068e3e139c7689bf8e8d38669896b8d082e65e458e1f82b8e8ec926e7aa0f97d08526e9636f2c00af4c2bd3d8bffc4bb93cd47b09af18883e11b639d47938d036f7cfeb77db74a2c09a6dee9df98b18eff2fda7d3f4135083bb3b59e2172244ec37bdbdcfe6e199d36dc949cda1cca123fb2be07803d003d76af3d7164453df77d44c7f2599636ca44d0b7a46218326b0c814ed322b9c4279b060f1b9e14b70f55a3751c4343763cdbf9c14637d2210c59fbd037be17ea6706846fdc7b9ab90278c01c458e64442f9256f3ad1cbceb22959d495063aaca1a3959eae03', + 'fa3751', + '6459ea1d443df706907ffdd3ca2f193f93f5a349b50357d26748b767cde6ab5cbfe76b1acb2b9eb97da5c4d2ddc8d18e3a3b1a0326d475c1c2c49ca73c0fd3fc9540cbbba85ac52d6811fabd693a3b09a281d535715ab784df3ad7292606d15a70ccd1a7e2b1b48ad92a6a3f736f9fd5522d9a869c7b654446102e9493b3ed9f', + '2b72942573b825cd1f0172119c23440a2b384b7f2a3c5582bb02f764e2b159ea9ad880ca61b3df7ca249134f4bec285083c7ebf984b192808e916af687ef6c6a9a6722a4fa9189fac1521d03853f3dd5a95ff4b9dbdbf3c7077f720650ead01945ab5bfee582ac1643526fbf68efe1bb3b6f7d2b4b01f2155aaea38a2c7ed29add23ee791a703d11e3b1b7c500d9a6b647c1337bf537c071e5bada6faa025bcaf5e5d1196998909c3d64758826939ae7fe1466dc6efc10a2b25e21186c2d135ceace33cdf490b13a0d10c2527e04200aa70bc1d4f3cfb04b5d2bc17aee881d3a788401f45443470bc639232088a9553c8d792aa5707654f075476a66b86368d5a92b4c84a3b4baba1b0b98bdebb85b48b82b8409f2e9c1aa500670329ff3b6e83e25c561110d47b2fe93ea2946a74f9730da9b7d126f8d7c3fa4a51fc30144a827831c186390998d552a1b677afe5afee46e9d4a5774a56355a4d1967677e75d176aef71c3fa061644d7a9582385877de67f87724b0a6e868f3a2eeafb68c53b', + algorithms.signing.rsa.sha512); + }); + + group('RSA-SSA-PSS', () { + @isTest + void doTest(String name, String modulus, String exponent, String message, + String signature, AlgorithmIdentifier algorithm) { + test(name, () { + var key = RsaPublicKey( + exponent: bigIntFromHexString(exponent), + modulus: bigIntFromHexString(modulus), + ); + + var verifier = key.createVerifier(algorithm); + verifier.verify(bytesFromHexString(message), + Signature(bytesFromHexString(signature))); + }); + } + + doTest( + 'Test case 1', + 'a47d04e7cacdba4ea26eca8a4c6e14563c2ce03b623b768c0d49868a57121301dbf783d82f4c055e73960e70550187d0af62ac3496f0a3d9103c2eb7919a72752fa7ce8c688d81e3aee99468887a15288afbb7acb845b7c522b5c64e678fcd3d22feb84b44272700be527d2b2025a3f83c2383bf6a39cf5b4e48b3cf2f56eef0dfff18555e31037b915248694876f3047814415164f2c660881e694b58c28038a032ad25634aad7b39171dee368e3d59bfb7299e4601d4587e68caaf8db457b75af42fc0cf1ae7caced286d77fac6cedb03ad94f1433d2c94d08e60bc1fdef0543cd2951e765b38230fdd18de5d2ca627ddc032fe05bbd2ff21e2db1c2f94d8b', + '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010e43f', + 'e002377affb04f0fe4598de9d92d31d6c786040d5776976556a2cfc55e54a1dcb3cb1b126bd6a4bed2a184990ccea773fcc79d246553e6c64f686d21ad4152673cafec22aeb40f6a084e8a5b4991f4c64cf8a927effd0fd775e71e8329e41fdd4457b3911173187b4f09a817d79ea2397fc12dfe3d9c9a0290c8ead31b6690a6', + '4f9b425c2058460e4ab2f5c96384da2327fd29150f01955a76b4efe956af06dc08779a374ee4607eab61a93adc5608f4ec36e47f2a0f754e8ff839a8a19b1db1e884ea4cf348cd455069eb87afd53645b44e28a0a56808f5031da5ba9112768dfbfca44ebe63a0c0572b731d66122fb71609be1480faa4e4f75e43955159d70f081e2a32fbb19a48b9f162cf6b2fb445d2d6994bc58910a26b5943477803cdaaa1bd74b0da0a5d053d8b1dc593091db5388383c26079f344e2aea600d0e324164b450f7b9b465111b7265f3b1b063089ae7e2623fc0fda8052cf4bf3379102fbf71d7c98e8258664ceed637d20f95ff0111881e650ce61f251d9c3a629ef222d', + algorithms.signing.rsa.pss.withParameters( + sigHash: algorithms.digest.sha256, + mgf1Hash: algorithms.digest.sha256, + saltLength: 32)); + doTest( + 'Test case 2', + '99a5c8d094a5f917034667a0408b7ecfcaacc3f9784444e21773c3461ec355f0d0f52a5db0568a71d388696788ef66ae7340c6b28dbf925fe83557986575f79cca69217221397ed5808a26f7e7e714c93235f914d45c4a9af4619b20f511ad644bd3412dfdf0ff717f7aac746f310bfa9a141ac3dbf01c1fc74febd197938419c262293505c35f402f9053ad13c51a5960ecde55ec829e953f941af733e58705913767e7a7200d1d09e7e7e2d269fa29a558bb16304b059f13f4ca560a8101fe3720b4a779ec126427326caa132a3d3611d7dbc50336fac789ec406b397e1e36d7daf9b624bf639c82b859288747690c730c980b2f5a239dd95ad5389a2ec90c5778604713710383ae55d4d28c06d4ac26f0d1231f1d6762c8e0d918118156bc637760daea184746b8dcf6f61db274a7ddceaa074937ababad4549b97ab992494a807208abd789823f5d75c4b994089c8072cfc254e0d8202fd896476e96ad9d309a0e8e7301282f07eb2ae8edefb7dbbe13b96e8b4024c6b84de0a05e150285', + '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008a649', + 'cc21593a6a0f737e2970b7c07984b070d761726296a07e24e056e68ff846b29cc1548179843d74dcee86479858b2c16e4cb84f2544b4ecdcb4dd43a04bb7183a768ae44a2712bf9ad47883acc2812f958306890ebea408c92eb4f001ed7dbf55f3a9c8d6d9f61e5fe32eb3253e59c18e863169478cd69b9155c335db66016f96', + '0aa572a6845b870b8909a683bb7e6e7616f77beff28746116d8bc4b7335546b51e8006ed0fc9a0d66f63ce0b9ebf792d7efd4305d7624d545400a5fd6a06b78f174b86803f7cd1cc93e3a97286f0ea590e40ff26195aa219fe1510a016785223606d9311a16c59a8fe4a6da6ecd0c1d7775039290c2aaa17ed1eb1b54374f7e572db13cca3a638575f8004aa54a2fa98422fc07e43ad3a20dd93001493442677d883914dc74ec1cbebbbd3d2b6bad4666d91457b69b46a1a61f21298f1a67942ec86c876322dd366ed167814e9c8fc9040c5b4b7a859bbd880cb6bc241b9e327ce779e0783b1cf445e0b2f5771b3f5822a1364391c154dc506fff1fb9d9a35f80199a6b30b4b92b92619a40e21aea19284015863c44866c61ed904a7ad19ee04d966c0aae390636243565581ff20bd6e3cfb6e31f5afba964b311dc2d023a21998c8dd50ca453699190bd467429e2f88ace29c4d1da4da61aac1eda2380230aa8dbb63c75a3c1ec04da3a1f880c9c747acdb74a8395af58f5f044015ccaf6e94', + algorithms.signing.rsa.pss.withParameters( + sigHash: algorithms.digest.sha512, + mgf1Hash: algorithms.digest.sha512, + saltLength: 0)); + }, skip: 'RSA-SSA-PSS not implemented'); + }); + + group('Hybrid Encryption', () { + group('ECIES with AEAD and HKDF', () { + @isTest + void doTest(String name, String privateKey, String ciphertext, + String encryptedKey, String plaintext) { + test(name, () { + var key = EcPrivateKey( + eccPrivateKey: bigIntFromHexString(privateKey), + curve: curves.p256, + ); + print(key); + // TODO + throw UnimplementedError(); + }); + } + + doTest( + 'Test Case 1', + '32588172ed65830571bb83748f7fddd383323208a7825c80a71bef846333eb02', + '0401b11f8c9bafe30ae13f8bd15528714e752631a4328bf146009068e99489c8e9fae1ec39e3fe9994723711417fcab2af4b3c9b60117d47d33d35175c87b483b8935a73312940d1fbf8da3944a89b5e8b', + 'some context info', + ''); + doTest( + 'Test Case 2', + '32588172ed65830571bb83748f7fddd383323208a7825c80a71bef846333eb02', + '040230023d1547b55af5a735a7f460722612126d7539d7cd0f677d308b29c6f52a964e66e7b0cb44cff1673df9e2c793f1477ca755807bfbeadcae1ab20b45ecb1501ca5e3f5b0626d3ca40aa5d010443d506e4df90b', + 'some context info', + 'hello'); + doTest( + 'Test Case 3', + '32588172ed65830571bb83748f7fddd383323208a7825c80a71bef846333eb02', + '0441ddd246cea0825bd68bddff05cec54a4ee678da35b2f5cfbbb32e5350bdd817214bfb7b5ed5528131bde56916062cfbd8b9952d9e0907a6e87e1de54db5df3aaccddd328efcf7771ce061e647488f66b8c11a9fca171dcff813e90b44b2739573f9f23b60202491870c7ff8aaf0ae46838e48f17f8dc1ad55b67809699dd31eb6ca50dfa9beeee32d30bdc00a1eb1d8b0cbcedbe50b1e24619cc5e79042f25f49e2c2d5a35c79e833c0d68e31a93da4173aacd0428b367594ed4636763d16c23e4f8c115d44bddc83bcefcaea13587238ce8b7a5d5fad53beeb59aaa1d7483eb4bac93ed50ed4d3e9fd5af760283fd38080b58744b73212a36039179ce6f96ef1ecaa05b5186967d81c06b9cd91140dfbd54084ddcfd941527719848a2eecb84278f6a0fe9357a3964f87222fcd16a12a353e1f64fd45dc227a4a2112da6f61269f22f16b41e68eadf0b6b3a48c67b9e7e3ec1c66eecce50dda8ecbce99d3778299aa28741b7247fbc46a1b8a908dc23943c2dd17210a270bb12b096c2c6a00400a95c62894a15b9fc44e709d27348f2f2644a786cd9e96caf42ea9b949f76e85e6f7365e15fa2902e851222c025f6c208269d799fcfc4c0b37aba8979ed9e6ccf543c217ee0b6ad05f0e3ffb92943d308c801b25efedab5bf93a733bdae611132d774d4b9ee4fb5e88ae63014315ae9571039a8c8c7020e2b3a1bbd4235b65af94771c8417c87fd6cab423b82a557f60a99ae7402dba205e05136dd34f0026fce87899d4b9819cc2b2ba686512d62c41a1e3a667a705ea45404aafa489cd7f53f42455fff3f9b22f960d12a2587efd6ed0fa3e00dd4645face1b2f1268e6019be70999eab00f0aeff3cb0e77b7c4a1ab1fdf15d00c4eedd7b75e8cf5c90119346894089ee0299d58f1d7ebac9b592da2325a5a738ea2baecc1468670f5aec880bce32efecfb2a7c5ad3ae4096b0a07aa9bfe6cbaf53da6757377bb692e55ec8caf5f0af28dafdc42e1d6e5893140945a853f56652c575b99d64399aad2d042948575134c8fe638fb0b80ac3a0f08a60f3aa817fe0a24c1fffee6933bd72ea460e0b241d3f5d98b2321ee25d8c0302353fcfd41bce964d73ff670422864506cc56f3470362c90144586ccbfc8e5e6fefbb70429b0a517e4b1badb449cd11092790aba6e19b914899872f4fb481c8dc47a33422fc05072ac99c958e40dae53d96ebd87cfbde67a0f050203a89e487da5e03364951830e43771d36abfbe8f5a7da8e7aa891f36a68dbe9a3b0e3dfbd1afd6327a3ced4a5cd8a5b256fef46d200df4af2e2da4dbb786ea0404bb968b6d961e4fc76f89e70ad7c9e11d6aee6526b75b399811f73c053a29582ba9295ea4d5a8fffb5a8ccbac008d291dd60e2041371acfc4c432a0ae0fcd8fa25c9551123c95da64caa134edaee5893e19c3c76075bef419c09681a67f4ede6f28d747b53afd61ddc937d7de96a22c7db10ad8700cade888de5d6f450c15d796978ddb5e6a52e5044e90247c988686d992105c85f6d198e2de859330f973ded4d7e5d90de57051dbaf0db0febd4cf9d44da155e55293b0930f89c1d21cc227eba9615ca47cce41d16eaddb5bf5dc9bc8477df5cf21f460b83241e7d0fa3707f9d2b322b9aaa42747d0653168b095ca0a83f38426688f6f10143cbd1b84c08583b09ed6192c7366ecc23af528fc2e8c585560f9bd0fcc255b82fc70723a92506bb475ebc1f5ae34a902bf2aa75997ed90a54762c8e83720833b2fd607eee1beb347a75d3bd0f174ed450a72cce79f1be426de9d6f1a6feff052674af141b3cea89f8e749118392e9533c62ddad870e60d509fd7abfa0bc33c2774b29a0170089b30d82047d6e130c49f6965f9871d1928b7f13e3e40ad8e3dc85195f4b312f9f6d8e4158aca23a611f6c6c798983555139942536f6ac59bbd6cc88b9933f22e81429e835bfd4fec27c67520d64a0ad8fd7feb6a3fbe52dc56cbbf59644b0fad0c462ed02ffbf7258e4b94bdedefb187fbdb729a0d56a36e876ac76de766eed416f39ab4e8b1982b8d0a87cd33182ae81ecf1d1d5202cc3e82c5762646d15db5f13cde3e81c83715195f9af9f27e01e1829ce529fa0f715db1f5d227bb201c7c127ea8d0e9c21739c7e9c6a0d8d5a1aaea5216c549f3715f889e583555ac1bfd77339f3eff1bee75ee2fc45457f5c3ffe9401b8b67f5bb3f305f3269fe6153ba34de3fa90016c76811cd54b4b49b17b244b1a4f6edfa2eaf46e2819aded26005b4ed712e8b700ae7b6123fa2c179640ee523f864360d116ee243f13c66d2cd61d422709648d905ab17edf0d0075d2fed443889e15344069b69b2d3d8273f197f8468baf167074bf6dfdeea5871f0c0652ab2801f394ef6fbf841e8072c8bf65026d85d441ca61e78785a2e7ca1e743640fecd6dfad8b77adcbb8bcb8ce8532ad0cd8b3e51269c26ad037545273f756c1a5511925408a5045af469ca947f9a3f5457bcc325d05291a192abe75b4da7c97a61adc2fa247984edb5a03285f1c3b99f13f6a22f007029faffdd38b62f7bf909ce602e4e06ab1ec4543013d354d0dd86d8933a53c17ead02faf0cc740d7191fe475be2f7940c234f8c73420774a7213fd2a477847527172c02a54928de5fde5f15616760e6f7ff3c03a233aec880a939d9f1ca68be7f474fd13184fe8f6deb0c4ea01617ea207d5d765d067fddba58b94f3b59d5996e9f5434f483e2f0079c48050f3ba941b589294c41a0f350451d566fe58a9c9688cc3a75da314ff4b3473eeac58664c5922ae4efae850fe0f7f11dcc089bc0b4df9a64547a35b2559f4a4a3e7d3782d850997baa589534921becde8dc3f76380ae36bd9730956aae9f59b121d8ae4dbbc586c6b45ad9d5c17cf6821b746177bc9fcb727db3f4aa190688c48826421de5ebcd429e0d9b479e66e676e8f9a3b4bd92621f47357a7b1b27942121f5a6e0087e4192a5f8cf4da942cc9d86eac5e', + 'some context info', + '08b8b2b733424243760fe426a4b54908632110a66c2f6591eabd3345e3e4eb98fa6e264bf09efe12ee50f8f54e9f77b1e355f6c50544e23fb1433ddf73be84d879de7c0046dc4996d9e773f4bc9efe5738829adb26c81b37c93a1b270b20329d658675fc6ea534e0810a4432826bf58c941efb65d57a338bbd2e26640f89ffbc1a858efcb8550ee3a5e1998bd177e93a7363c344fe6b199ee5d02e82d522c4feba15452f80288a821a579116ec6dad2b3b310da903401aa62100ab5d1a36553e06203b33890cc9b832f79ef80560ccb9a39ce767967ed628c6ad573cb116dbefefd75499da96bd68a8a97b928a8bbc103b6621fcde2beca1231d206be6cd9ec7aff6f6c94fcd7204ed3455c68c83f4a41da4af2b74ef5c53f1d8ac70bdcb7ed185ce81bd84359d44254d95629e9855a94a7c1958d1f8ada5d0532ed8a5aa3fb2d17ba70eb6248e594e1a2297acbbb39d502f1a8c6eb6f1ce22b3de1a1f40cc24554119a831a9aad6079cad88425de6bde1a9187ebb6092cf67bf2b13fd65f27088d78b7e883c8759d2c4f5c65adb7553878ad575f9fad878e80a0c9ba63bcbcc2732e69485bbc9c90bfbd62481d9089beccf80cfe2df16a2cf65bd92dd597b0707e0917af48bbb75fed413d238f5555a7a569d80c3414a8d0859dc65a46128bab27af87a71314f318c782b23ebfe808b82b0ce26401d2e22f04d83d1255dc51addd3b75a2b1ae0784504df543af8969be3ea7082ff7fc9888c144da2af58429ec96031dbcad3dad9af0dcbaaaf268cb8fcffead94f3c7ca495e056a9b47acdb751fb73e666c6c655ade8297297d07ad1ba5e43f1bca32301651339e22904cc8c42f58c30c04aafdb038dda0847dd988dcda6f3bfd15c4b4c4525004aa06eeff8ca61783aacec57fb3d1f92b0fe2fd1a85f6724517b65e614ad6808d6f6ee34dff7310fdc82aebfd904b01e1dc54b2927094b2db68d6f903b68401adebf5a7e08d78ff4ef5d63653a65040cf9bfd4aca7984a74d37145986780fc0b16ac451649de6188a7dbdf191f64b5fc5e2ab47b57f7f7276cd419c17a3ca8e1b939ae49e488acba6b965610b5480109c8b17b80e1b7b750dfc7598d5d5011fd2dcc5600a32ef5b52a1ecc820e308aa342721aac0943bf6686b64b2579376504ccc493d97e6aed3fb0f9cd71a43dd497f01f17c0e2cb3797aa2a2f256656168e6c496afc5fb93246f6b1116398a346f1a641f3b041e989f7914f90cc2c7fff357876e506b50d334ba77c225bc307ba537152f3f1610e4eafe595f6d9d90d11faa933a15ef1369546868a7f3a45a96768d40fd9d03412c091c6315cf4fde7cb68606937380db2eaaa707b4c4185c32eddcdd306705e4dc1ffc872eeee475a64dfac86aba41c0618983f8741c5ef68d3a101e8a3b8cac60c905c15fc910840b94c00a0b9d0'); + }, skip: 'ECIES with AEAD and HKDF not implemented'); + }); +} + +Uint8List bytesFromHexString(String s) => Uint8List.fromList(List.generate( + s.length ~/ 2, (i) => int.parse(s.substring(i * 2, i * 2 + 2), radix: 16))); + +BigInt bigIntFromHexString(String s) => bytesFromHexString(s) + .fold(BigInt.zero, (a, b) => a * BigInt.from(256) + BigInt.from(b)); diff --git a/packages/crypto_keys_plus/test/unexpected_key_type_test.dart b/packages/crypto_keys_plus/test/unexpected_key_type_test.dart new file mode 100644 index 00000000..b6b44377 --- /dev/null +++ b/packages/crypto_keys_plus/test/unexpected_key_type_test.dart @@ -0,0 +1,111 @@ +// Targets the defensive "unexpected/unknown key type" fallback branches in +// `_AsymmetricOperator` (asymmetric_operator.dart) and `DefaultSecureRandom` +// (algorithms.dart), both of which are unreachable through the package's +// normal key-generation/JWK-parsing surface and require directly +// implementing the `Key`/`PrivateKey`/`PublicKey`/`EcKey` mixins to reach. +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +// DefaultSecureRandom is intentionally not part of the public `show` list on +// the `crypto_keys.dart` export, so it must be reached via its `src/` path. +import 'package:crypto_keys_plus/src/algorithms.dart' show DefaultSecureRandom; +import 'package:pointycastle/export.dart' as pc; +import 'package:test/test.dart'; + +/// An `EcKey` that is deliberately neither an `EcPrivateKey` nor an +/// `EcPublicKey`. `EcKey` is a plain (non-sealed) abstract class, so this is +/// a legitimate way to reach `_AsymmetricOperator.keyParameter`'s final +/// `throw StateError('Unexpected key type $key')`: the getter always +/// computes `ecDomainParameters` (which only requires `key is EcKey`) before +/// checking for the two concrete EC subtypes. +class _EcKeyOfUnknownKind extends EcKey with PrivateKey { + @override + Identifier get curve => curves.p256; +} + +/// A `PublicKey` that is neither a `SymmetricKey`, an `OkpPublicKey`, nor an +/// `EcKey`/`RsaKey`. Reaches `_AsymmetricVerifier.verify`'s final +/// `throw UnsupportedError('Unknown key type $key')`, which — unlike the +/// signer's equivalent branch — is checked only inside the `RsaKey`/`EcKey` +/// `if` branches, so no cast happens first for a key of neither kind. +class _NeitherRsaNorEcPublicKey with Key, PublicKey {} + +void main() { + group('_AsymmetricOperator.keyParameter unexpected-key-type branch', () { + test( + 'signing with an EcKey that is neither EcPrivateKey nor EcPublicKey ' + 'throws StateError', () { + final key = _EcKeyOfUnknownKind(); + final signer = key.createSigner(algorithms.signing.ecdsa.sha256); + expect( + () => signer.sign(Uint8List.fromList([1, 2, 3])), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unexpected key type'), + )), + ); + }); + }); + + group('_AsymmetricVerifier.verify unknown-key-type branch', () { + test( + 'verifying with a key that is neither RsaKey nor EcKey throws ' + 'UnsupportedError without ever computing keyParameter', () { + final key = _NeitherRsaNorEcPublicKey(); + final verifier = key.createVerifier(algorithms.signing.ecdsa.sha256); + expect( + () => verifier.verify( + Uint8List.fromList([1, 2, 3]), + Signature(Uint8List.fromList([0])), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unknown key type'), + )), + ); + }); + }); + + group('DefaultSecureRandom', () { + // Not exported from the public `crypto_keys.dart` barrel, but every + // asymmetric key-generation and encryption path in the library routes + // through it, so its full `pc.SecureRandom` surface (including the + // members pointycastle's own call sites never happen to use) is part of + // the documented contract via `implements pc.SecureRandom`. + test('algorithmName reports the underlying dart:math source', () { + expect( + DefaultSecureRandom().algorithmName, + 'dart.math.Random.secure()', + ); + }); + + test('nextUint16 stays within the 16-bit range', () { + final random = DefaultSecureRandom(); + for (var i = 0; i < 50; i++) { + final v = random.nextUint16(); + expect(v, inInclusiveRange(0, 0xffff)); + } + }); + + test('nextUint32 stays within the 32-bit range', () { + final random = DefaultSecureRandom(); + for (var i = 0; i < 50; i++) { + final v = random.nextUint32(); + expect(v, inInclusiveRange(0, 0xffffffff)); + } + }); + + test('seed is unsupported', () { + expect( + () => DefaultSecureRandom().seed(pc.KeyParameter(Uint8List(1))), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Seed not supported'), + )), + ); + }); + }); +} diff --git a/packages/jose_plus/.gitignore b/packages/jose_plus/.gitignore new file mode 100644 index 00000000..96407528 --- /dev/null +++ b/packages/jose_plus/.gitignore @@ -0,0 +1,16 @@ +# Files and directories created by pub +.dart_tool/ +.packages +# Remove the following pattern if you wish to check in your lock file +pubspec.lock + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ + +jose.iml + +# oh-my-claudecode local state +.omc/ diff --git a/packages/jose_plus/CHANGELOG.md b/packages/jose_plus/CHANGELOG.md new file mode 100644 index 00000000..6bf768ed --- /dev/null +++ b/packages/jose_plus/CHANGELOG.md @@ -0,0 +1,171 @@ +## 1.0.1 + + - **FIX**(jose_plus): pass apu/apv to the ECDH-ES Concat KDF in build(). ([aa20db62](https://github.com/Bdaya-Dev/oidc/commit/aa20db62140e7a43cf60224f2a163515aa101314)) + +## 1.0.0 + +> Note: This release has breaking changes. + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(ci): stop swallowing web test failures; pin dart:io fixture tests to vm ([#363](https://github.com/Bdaya-Dev/oidc/issues/363)). ([6102d835](https://github.com/Bdaya-Dev/oidc/commit/6102d8353b0d330f888ea8b8e9b4c52937be2236)) + - **FIX**(release): add package-name entrypoints for jose_plus and x509_plus. ([4bbcb7f3](https://github.com/Bdaya-Dev/oidc/commit/4bbcb7f3df69cf393be29bd5997f49c15add7ff4)) + - **FIX**(jose_plus): normalize crypto_keys_plus/x509_plus constraints to caret syntax. ([d0a1d55d](https://github.com/Bdaya-Dev/oidc/commit/d0a1d55da2507e2f12b1244dcedaa1481fad1aed)) + - **FEAT**(crypto): RSASSA-PSS (PS256/384/512) + EdDSA/Ed25519. ([6c5c6741](https://github.com/Bdaya-Dev/oidc/commit/6c5c674149fe0504207c95aefbda7fedfca72ae9)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + +## 0.4.8 + - **FIX**: JWE JSON parsing now handles missing `recipients` / `header` by deriving a single recipient from the protected header and validates absent `encrypted_key` for non-`dir` algorithms. + - **FEAT**: Support unpadded Base64URL protected header (new parsing test). + - **TEST**: Added fallback, AAD, key wrap, and error path tests. + - **CHORE**: Refactored `JsonWebEncryption.fromJson` for clarity. + +## [0.5.0](https://github.com/Bdaya-Dev/jose/compare/v0.4.8...v0.5.0) (2026-05-11) + + +### Features + +* add support for es256k algorithm ([a2d046a](https://github.com/Bdaya-Dev/jose/commit/a2d046a334a9060fc258610ce2e23c4865bfa3b3)) +* implement ECDH-ES key agreement (ECDH-ES, ECDH-ES+A*KW) ([280ccaa](https://github.com/Bdaya-Dev/jose/commit/280ccaabf2ab9c4142e78fc1ee6411ce235776c1)) +* publish jose_plus ([35d4471](https://github.com/Bdaya-Dev/jose/commit/35d44717e8175f50c26d9221bdcdc06aee46871b)) +* remove dependency on package:collection ([77f99be](https://github.com/Bdaya-Dev/jose/commit/77f99be1524fa81d23e4460f8cd8611b4c226736)) +* support compressed data in jwe ([4f1dd72](https://github.com/Bdaya-Dev/jose/commit/4f1dd72d1b764fa174e11546f47efbc68a83f37c)) +* Updated dependencies: crypto_keys_plus and x509_plus ([d24a89f](https://github.com/Bdaya-Dev/jose/commit/d24a89f5127c0b9f68ff40bc0822bd1ab2053183)) +* Updated dependencies: crypto_keys_plus to 0.5.0 and x509_plus to 0.3.3. ([d2d8b1d](https://github.com/Bdaya-Dev/jose/commit/d2d8b1d3befd571d8d2ffaa59e5ef67e7573cd31)) +* use clock package ([a762b4a](https://github.com/Bdaya-Dev/jose/commit/a762b4a12e7549cbf211e2fcda627691b9a0f4d4)) +* use clock package ([157694d](https://github.com/Bdaya-Dev/jose/commit/157694d6bdec8ff0b3d6e54eb2341e3471ac4d20)) + + +### Bug Fixes + +* 26 ([b91fb1e](https://github.com/Bdaya-Dev/jose/commit/b91fb1edcfa89cee333a415f8a3ef252844b4775)) +* add missing keyId when constructing a JWK with EcPublicKey (pull request [#38](https://github.com/Bdaya-Dev/jose/issues/38) from tallinn1960) ([b8d11f3](https://github.com/Bdaya-Dev/jose/commit/b8d11f325914ead348ae97fa7e344eb3dca7ee8f)) +* allow double values when converting to DateTime and Duration (pull request [#33](https://github.com/Bdaya-Dev/jose/issues/33) from PixelToast) ([3b204b1](https://github.com/Bdaya-Dev/jose/commit/3b204b10101c7db7dc275279dcc4090a1494d238)) +* check if the keyIds exist before checking for the keyId mismatch ([a16a31c](https://github.com/Bdaya-Dev/jose/commit/a16a31c9fc4109aedd59b2b26dbc915909f09533)) +* check if the keyIds exist before checking for the keyId mismatch ([4d5feb5](https://github.com/Bdaya-Dev/jose/commit/4d5feb5a3d4f0ec35f0be5c7f1442c893f99ff55)) +* improved key resolution in JsonWebKeyStore ([b07799a](https://github.com/Bdaya-Dev/jose/commit/b07799aac1f56a9a21483feac026272aab30cc5d)) +* **jwe:** fallback parsing for missing recipients/header, support unpadded protected header; refactor fromJson and add focused tests (fallback, AAD, key wrap, error paths). ([ef8b783](https://github.com/Bdaya-Dev/jose/commit/ef8b78364c35a927518154ee8f41db3bc36201f8)) +* key_ops JWK field according to the RFC (pull request [#62](https://github.com/Bdaya-Dev/jose/issues/62) from sgt) ([331df9f](https://github.com/Bdaya-Dev/jose/commit/331df9fff020e808b9bd902f453554afe82c80c9)) +* make unprotected header in JWE optional (pull request [#43](https://github.com/Bdaya-Dev/jose/issues/43) from heacare) ([aefeeb0](https://github.com/Bdaya-Dev/jose/commit/aefeeb043fd5203314a691deaece87fb4fbc54c2)) +* merge upstream - fix JWK header key trust vulnerability ([a0b51cd](https://github.com/Bdaya-Dev/jose/commit/a0b51cd5d1a1e44d3489318b40a0aab1de99fddf)) +* parse jws from json with multiple signatures ([#57](https://github.com/Bdaya-Dev/jose/issues/57)) ([87215b7](https://github.com/Bdaya-Dev/jose/commit/87215b7868f0402d9868928e8be756c0111b5cfb)) +* parsing jws from json ([d4b19d2](https://github.com/Bdaya-Dev/jose/commit/d4b19d2254103f92aab13e71c04cffbab61eec88)) +* parsing jws from json ([05c04f4](https://github.com/Bdaya-Dev/jose/commit/05c04f4329d1ea0fbe54ab57b7ecb602fc541635)) +* proper error when there is a missing audience claim. ([05f4db7](https://github.com/Bdaya-Dev/jose/commit/05f4db7523f7106dc8b5c9ce06ddb57f268df062)) +* try all keys when kid is absent ([8fd4860](https://github.com/Bdaya-Dev/jose/commit/8fd486004f267bd728372f2dc549c87ffb27fd19)) +* type mismatch error on keyOperations getter (pull request [#37](https://github.com/Bdaya-Dev/jose/issues/37) from samataro) ([8afde0f](https://github.com/Bdaya-Dev/jose/commit/8afde0fda8f0e5232e115dbeff25d2367b7521cb)) +* use 12 byte iv with AESGCM (pull request [#39](https://github.com/Bdaya-Dev/jose/issues/39) from tallinn1960) ([5b7e24d](https://github.com/Bdaya-Dev/jose/commit/5b7e24da01fc3e782203ace5be9752055b54b33d)) + + +### Code Refactoring + +* support for archive ^4.0.0 ([d6150a5](https://github.com/Bdaya-Dev/jose/commit/d6150a5aae365127936c821888e0923ac2ba45d3)) + +## 0.4.7 + - **DEPS**: Remove dependency on `package:collection` + - **UPGRADE**: Updated to use crypto_keys_plus 0.5.0 which uses pointycastle 4.0.0. + - **UPGRADE**: Updated to use x509_plus 0.3.3. + +## 0.4.6 + + - **FIX**: parsing jws from json. ([05c04f43](https://github.com/appsup-dart/jose/commit/05c04f4329d1ea0fbe54ab57b7ecb602fc541635)) + + +## 0.4.5 + + - **FIX**: check if the keyIds exist before checking for the keyId mismatch. ([4d5feb5a](https://github.com/appsup-dart/jose/commit/4d5feb5a3d4f0ec35f0be5c7f1442c893f99ff55)) + +## 0.4.4 + + - **FIX**: proper error when there is a missing audience claim. ([05f4db75](https://github.com/appsup-dart/jose/commit/05f4db7523f7106dc8b5c9ce06ddb57f268df062)) + - **FEAT**: use clock package. ([157694d6](https://github.com/appsup-dart/jose/commit/157694d6bdec8ff0b3d6e54eb2341e3471ac4d20)) + +## 0.4.3 + - **fix**: proper error when there is a missing `aud` claim. + +## 0.4.2 + - **feat**: add `sid` to JWT claims. + +## 0.4.1 + - **FIX**: lower `package:collection` constraints. + +## 0.4.0 + - **FIX**: use `x509_plus` and `crypto_keys_plus`. + +## 0.3.5+1 + + - **FIX**: try all keys when kid is absent. ([8fd48600](https://github.com/appsup-dart/jose/commit/8fd486004f267bd728372f2dc549c87ffb27fd19)) + - **FIX**: parse jws from json with multiple signatures ([#57](https://github.com/appsup-dart/jose/issues/57)). ([87215b78](https://github.com/appsup-dart/jose/commit/87215b7868f0402d9868928e8be756c0111b5cfb)) + - **FIX**: key_ops JWK field according to the RFC (pull request [#62](https://github.com/appsup-dart/jose/issues/62) from sgt). ([331df9ff](https://github.com/appsup-dart/jose/commit/331df9fff020e808b9bd902f453554afe82c80c9)) + - **FIX**: improved key resolution in JsonWebKeyStore. ([b07799aa](https://github.com/appsup-dart/jose/commit/b07799aac1f56a9a21483feac026272aab30cc5d)) + + +## 0.3.5 + + - **REFACTOR**: support for archive ^4.0.0. ([d6150a5a](https://github.com/appsup-dart/jose/commit/d6150a5aae365127936c821888e0923ac2ba45d3)) + - **FEAT**: support compressed data in jwe. ([4f1dd72d](https://github.com/appsup-dart/jose/commit/4f1dd72d1b764fa174e11546f47efbc68a83f37c)) + +## 0.3.4 + + - **FEAT**: Support latest `package:http` ([#50](https://github.com/appsup-dart/jose/pull/50)) + + +## 0.3.3 + + - **FIX**: allow double values when converting to DateTime and Duration (pull request [#33](https://github.com/appsup-dart/jose/issues/33) from PixelToast). ([3b204b10](https://github.com/appsup-dart/jose/commit/3b204b10101c7db7dc275279dcc4090a1494d238)) + - **FIX**: type mismatch error on keyOperations getter (pull request [#37](https://github.com/appsup-dart/jose/issues/37) from samataro). ([8afde0fd](https://github.com/appsup-dart/jose/commit/8afde0fda8f0e5232e115dbeff25d2367b7521cb)) + - **FIX**: add missing keyId when constructing a JWK with EcPublicKey (pull request [#38](https://github.com/appsup-dart/jose/issues/38) from tallinn1960). ([b8d11f32](https://github.com/appsup-dart/jose/commit/b8d11f325914ead348ae97fa7e344eb3dca7ee8f)) + - **FIX**: use 12 byte iv with AESGCM (pull request [#39](https://github.com/appsup-dart/jose/issues/39) from tallinn1960). ([5b7e24da](https://github.com/appsup-dart/jose/commit/5b7e24da01fc3e782203ace5be9752055b54b33d)) + - **FIX**: make unprotected header in JWE optional (pull request [#43](https://github.com/appsup-dart/jose/issues/43) from heacare). ([aefeeb04](https://github.com/appsup-dart/jose/commit/aefeeb043fd5203314a691deaece87fb4fbc54c2)) + - **FEAT**: add support for es256k algorithm. ([a2d046a3](https://github.com/appsup-dart/jose/commit/a2d046a334a9060fc258610ce2e23c4865bfa3b3)) + + +## 0.3.2 + +- Compatible with version `0.3.0` of `crypto_keys` + +## 0.3.1 + +- JsonWebKey.parsePem handles CERTIFICATE +- `DefaultJsonWebKeySetLoader`: if possible, use HTTP headers to determine cache expiration. + +## 0.3.0 + +- Migrate null safety + +## 0.2.2 +- Bump `asn1lib` to 0.8.1. + +## 0.2.1+1 + +- Fix docs + +## 0.2.1 + +- Added JsonWebKey constructors for creating EC and RSA keys +- Added factory constructor for creating a JsonWebKey from crypto keys +- Added factory constructor for creating a JsonWebKey from a pem string +- Support for P-256K curve + +## 0.2.0 + +- Support RSAES-OAEP +- Allow x509 parameters in JWK +- JsonWebAlgorithm class +- Generating random non-symmetric keys +- cryptoKeyPair getter on JsonWebKey returning a `KeyPair` from `crypto_keys` package +- **Breaking Change**: loading jwk set from `package` or `file` url no longer supported by default. The new class +`JsonWebKeySetLoader` can be used to override this behavior or manage the way jwk sets are loaded from an url. + +## 0.1.2 + +- Add `allowedAlgorithms` argument also in JWT + +## 0.1.1 + +- Fix security issue: JWS with algorithm `none` was previously verified, +now you can specify which algorithms are allowed and by default `none` is +not allowed. + +## 0.1.0 + +- Initial version diff --git a/packages/jose_plus/LICENSE b/packages/jose_plus/LICENSE new file mode 100644 index 00000000..d59994b1 --- /dev/null +++ b/packages/jose_plus/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2018, Rik Bellens. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/jose_plus/README.md b/packages/jose_plus/README.md new file mode 100644 index 00000000..2412ef15 --- /dev/null +++ b/packages/jose_plus/README.md @@ -0,0 +1,338 @@ + +[![Build Status](https://travis-ci.org/Bdaya-Dev/jose.svg?branch=master)](https://travis-ci.org/Bdaya-Dev/jose) + +[![Ceasefire Now](https://badge.techforpalestine.org/default)](https://techforpalestine.org/learn-more) + +[:heart: sponsor](https://github.com/sponsors/rbellens) + + + +Javascript Object Signing and Encryption (JOSE) library + +Supports JSON Web Signature (JWS), JSON Web Encryption (JWE), +JSON Web Key (JWK) and JSON Web Token (JWT). + +## Supported JSON Web Algorithms + +This package supports a lot of the algorithms defined in the standards. + +### Siging algorithms for JWS + +* *HS256* HMAC using SHA-256 +* *HS384* HMAC using SHA-384 +* *HS512* HMAC using SHA-512 +* *RS256* RSASSA-PKCS1-v1_5 using SHA-256 +* *RS384* RSASSA-PKCS1-v1_5 using SHA-384 +* *RS512* RSASSA-PKCS1-v1_5 using SHA-512 +* *ES256* ECDSA using P-256 and SHA-256 +* *ES256K* ECDSA using P-256K and SHA-256 +* *ES384* ECDSA using P-384 and SHA-384 +* *ES512* ECDSA using P-521 and SHA-512 +* *none* No digital signature or MAC + +### Key wrapping algorithms for JWE + +* *RSA1_5* RSAES-PKCS1-v1_5 +* *RSA-OAEP* RSAES OAEP using default parameters +* *RSA-OAEP-256* RSAES OAEP using SHA-256 and MGF1 with SHA-256 +* *A128KW* AES Key Wrap with default initial value using 128-bit key +* *A192KW* AES Key Wrap with default initial value using 192-bit key +* *A256KW* AES Key Wrap with default initial value using 256-bit key +* *dir* Direct use of a shared symmetric key as the CEK + +### Encryption Algorithms for JWE + +* *A128CBC-HS256* AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm +* *A192CBC-HS384* AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm +* *A256CBC-HS512* AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm +* *A128GCM* AES GCM using 128-bit key +* *A192GCM* AES GCM using 192-bit key +* *A256GCM* AES GCM using 256-bit key + + + +## Usage + +### Decode and verify a JWS + +```dart +main() async { + var encoded = "eyJhbGciOiJFUzUxMiJ9." + "UGF5bG9hZA." + "AdwMgeerwtHoh-l192l60hp9wAHZFVJbLfD_UxMi70cwnZOYaRI1bKPWROc-mZZq" + "wqT2SI-KGDKB34XO0aw_7XdtAG8GaSwFKdCAPZgoXD2YBJZCPEX3xKpRwcdOO8Kp" + "EHwJjyqOgzDO7iKvU8vcnwNrmxYbSW9ERBXukOXolLzeO_Jn"; + + // create a JsonWebSignature from the encoded string + var jws = JsonWebSignature.fromCompactSerialization(encoded); + + // extract the payload + var payload = jws.unverifiedPayload; + + print("content of jws: ${payload.stringContent}"); + print("protected parameters: ${payload.protectedHeader.toJson()}"); + + // create a JsonWebKey for verifying the signature + var jwk = new JsonWebKey.fromJson({ + "kty": "EC", + "crv": "P-521", + "x": "AekpBQ8ST8a8VcfVOTNl353vSrDCLLJXmPk06wTjxrrjcBpXp5EOnYG_" + "NjFZ6OvLFV1jSfS9tsz4qUxcWceqwQGk", + "y": "ADSmRA43Z1DSNx_RvcLI87cdL07l6jQyyBXMoxVg_l2Th-x3S1WDhjDl" + "y79ajL4Kkd0AZMaZmh9ubmf63e3kyMj2", + "d": "AY5pb7A0UFiB3RELSD64fTLOSV_jazdF7fLYyuTw8lOfRhWg6Y6rUrPA" + "xerEzgdRhajnu0ferB0d53vM9mE15j2C" + }); + var keyStore = new JsonWebKeyStore()..addKey(jwk); + + // verify the signature + var verified = await jws.verify(keyStore); + print("signature verified: $verified"); +} + +``` + + +### Create a JWS + +```dart +main() async { + // create a builder + var builder = new JsonWebSignatureBuilder(); + + // set the content + builder.stringContent = "It is me"; + + // set some protected header + builder.setProtectedHeader("createdAt", new DateTime.now().toIso8601String()); + + // add a key to sign, you can add multiple keys for different recipients + builder.addRecipient( + new JsonWebKey.fromJson({ + "kty": "oct", + "k": + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" + }), + algorithm: "HS256"); + + // build the jws + var jws = builder.build(); + + // output the compact serialization + print("jws compact serialization: ${jws.toCompactSerialization()}"); + + // output the json serialization + print("jws json serialization: ${jws.toJson()}"); +} +``` + +### Decode and decrypt a JWE + +```dart +main() async { + var encoded = "eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0." + "UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm" + "1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7Pc" + "HALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIF" + "NPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8" + "rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv" + "-B3oWh2TbqmScqXMR4gp_A." + "AxY8DCtDaGlsbGljb3RoZQ." + "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY." + "9hH0vgRfYgPnAHOd8stkvw"; + + // create a JsonWebEncryption from the encoded string + var jwe = JsonWebEncryption.fromCompactSerialization(encoded); + + // create a JsonWebKey for decrypting the signature + var jwk = new JsonWebKey.fromJson( + { + "kty": "RSA", + "n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl" + "UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre" + "cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_" + "7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI" + "Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU" + "7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw", + "e": "AQAB", + "d": "VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq" + "1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry" + "nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_" + "0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj" + "-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj" + "T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ", + "p": "9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68" + "ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP" + "krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM", + "q": "uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y" + "BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN" + "-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0", + "dp": "w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv" + "ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra" + "Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs", + "dq": "o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff" + "7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_" + "odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU", + "qi": "eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC" + "tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ" + "B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo" + }, + ); + var keyStore = new JsonWebKeyStore()..addKey(jwk); + + // decrypt the payload + var payload = await jwe.getPayload(keyStore); + print("decrypted content: ${payload.stringContent}"); +} +``` + +### Create a JWE + +```dart +main() async { + // create a builder + var builder = new JsonWebEncryptionBuilder(); + + // set the content + builder.stringContent = "This is my bigest secret"; + + // set some protected header + builder.setProtectedHeader("createdAt", new DateTime.now().toIso8601String()); + + // add a key to encrypt the Content Encryption Key + var jwk = new JsonWebKey.fromJson( + { + "kty": "RSA", + "n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl" + "UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre" + "cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_" + "7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI" + "Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU" + "7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw", + "e": "AQAB", + "d": "VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq" + "1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry" + "nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_" + "0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj" + "-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj" + "T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ", + "p": "9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68" + "ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP" + "krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM", + "q": "uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y" + "BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN" + "-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0", + "dp": "w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv" + "ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra" + "Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs", + "dq": "o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff" + "7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_" + "odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU", + "qi": "eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC" + "tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ" + "B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo" + }, + ); + builder.addRecipient(jwk, algorithm: "RSA1_5"); + + // set the content encryption algorithm to use + builder.encryptionAlgorithm = "A128CBC-HS256"; + + // build the jws + var jwe = builder.build(); + + // output the compact serialization + print("jwe compact serialization: ${jwe.toCompactSerialization()}"); + + // output the json serialization + print("jwe json serialization: ${jwe.toJson()}"); +} +``` + + +### Decode and verify and validate a JWT + +```dart +main() async { + var encoded = "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9." + "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt" + "cGxlLmNvbS9pc19yb290Ijp0cnVlfQ." + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + + // decode the jwt, note: this constructor can only be used for JWT inside JWS + // structures + var jwt = new JsonWebToken.unverified(encoded); + + // output the claims + print("claims: ${jwt.claims}"); + + // create key store to verify the signature + var keyStore = new JsonWebKeyStore() + ..addKey(new JsonWebKey.fromJson({ + "kty": "oct", + "k": + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" + })); + + var verified = await jwt.verify(keyStore); + print("verified: $verified"); + + // alternatively, create and verify the JsonWebToken together, this is also + // applicable for JWT inside JWE + jwt = await JsonWebToken.decodeAndVerify(encoded, keyStore); + + // validate the claims + var violations = jwt.claims.validate(issuer: Uri.parse("alice")); + print("violations: $violations"); +} +``` + + +### Create a JWT + +```dart + +main() async { + var claims = new JsonWebTokenClaims.fromJson({ + "exp": new Duration(hours: 4).inSeconds, + "iss": "alice", + }); + + // create a builder, decoding the JWT in a JWS, so using a + // JsonWebSignatureBuilder + var builder = new JsonWebSignatureBuilder(); + + // set the content + builder.jsonContent = claims.toJson(); + + // add a key to sign, can only add one for JWT + builder.addRecipient( + new JsonWebKey.fromJson({ + "kty": "oct", + "k": + "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" + }), + algorithm: "HS256"); + + // build the jws + var jws = builder.build(); + + // output the compact serialization + print("jwt compact serialization: ${jws.toCompactSerialization()}"); +} +``` + + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/Bdaya-Dev/jose/issues + + +## Sponsor + +Creating and maintaining this package takes a lot of time. If you like the result, please consider to [:heart: sponsor](https://github.com/sponsors/rbellens). +With your support, I will be able to further improve and support this project. +Also, check out my other dart packages at [pub.dev](https://pub.dev/packages?q=publisher%3Aappsup.be). + diff --git a/packages/jose_plus/analysis_options.yaml b/packages/jose_plus/analysis_options.yaml new file mode 100644 index 00000000..3322f666 --- /dev/null +++ b/packages/jose_plus/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:lints/recommended.yaml + +# For lint rules and documentation, see http://dart-lang.github.io/linter/lints. +# Uncomment to specify additional rules. +# linter: +# rules: +# - camel_case_types + +analyzer: +# exclude: +# - path/to/excluded/files/** diff --git a/packages/jose_plus/dart_test.yaml b/packages/jose_plus/dart_test.yaml new file mode 100644 index 00000000..566774de --- /dev/null +++ b/packages/jose_plus/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + slow-web-crypto: diff --git a/packages/jose_plus/example/jose_example.dart b/packages/jose_plus/example/jose_example.dart new file mode 100644 index 00000000..2b28caa3 --- /dev/null +++ b/packages/jose_plus/example/jose_example.dart @@ -0,0 +1,421 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:jose_plus/jose.dart'; + +void main() async { + await example1(); + await example2(); + await example3(); + await example4(); + await example5(); + await example6(); + await example7(); + await example8(); + await example9(); + await example10(); +} + +// decode and verify a JWS +Future example1() async { + var encoded = 'eyJhbGciOiJFUzUxMiJ9.' + 'UGF5bG9hZA.' + 'AdwMgeerwtHoh-l192l60hp9wAHZFVJbLfD_UxMi70cwnZOYaRI1bKPWROc-mZZq' + 'wqT2SI-KGDKB34XO0aw_7XdtAG8GaSwFKdCAPZgoXD2YBJZCPEX3xKpRwcdOO8Kp' + 'EHwJjyqOgzDO7iKvU8vcnwNrmxYbSW9ERBXukOXolLzeO_Jn'; + + // create a JsonWebSignature from the encoded string + var jws = JsonWebSignature.fromCompactSerialization(encoded); + + // extract the payload + var payload = jws.unverifiedPayload; + + print('content of jws: ${payload.stringContent}'); + print('protected parameters: ${payload.protectedHeader!.toJson()}'); + + // create a JsonWebKey for verifying the signature + var jwk = JsonWebKey.fromJson({ + 'kty': 'EC', + 'crv': 'P-521', + 'x': 'AekpBQ8ST8a8VcfVOTNl353vSrDCLLJXmPk06wTjxrrjcBpXp5EOnYG_' + 'NjFZ6OvLFV1jSfS9tsz4qUxcWceqwQGk', + 'y': 'ADSmRA43Z1DSNx_RvcLI87cdL07l6jQyyBXMoxVg_l2Th-x3S1WDhjDl' + 'y79ajL4Kkd0AZMaZmh9ubmf63e3kyMj2', + 'd': 'AY5pb7A0UFiB3RELSD64fTLOSV_jazdF7fLYyuTw8lOfRhWg6Y6rUrPA' + 'xerEzgdRhajnu0ferB0d53vM9mE15j2C' + }); + var keyStore = JsonWebKeyStore()..addKey(jwk); + + // verify the signature + var verified = await jws.verify(keyStore); + print('signature verified: $verified'); +} + +// create a JWS +Future example2() async { + // create a builder + var builder = JsonWebSignatureBuilder(); + + // set the content + builder.stringContent = 'It is me'; + + // set some protected header + builder.setProtectedHeader('createdAt', DateTime.now().toIso8601String()); + + // add a key to sign, you can add multiple keys for different recipients + builder.addRecipient( + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + }), + algorithm: 'HS256'); + + // build the jws + var jws = builder.build(); + + // output the compact serialization + print('jws compact serialization: ${jws.toCompactSerialization()}'); + + // output the json serialization + print('jws json serialization: ${jws.toJson()}'); +} + +// decode and decrypt a JWE +Future example3() async { + var encoded = 'eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.' + 'UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm' + '1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7Pc' + 'HALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIF' + 'NPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8' + 'rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv' + '-B3oWh2TbqmScqXMR4gp_A.' + 'AxY8DCtDaGlsbGljb3RoZQ.' + 'KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY.' + '9hH0vgRfYgPnAHOd8stkvw'; + + // create a JsonWebEncryption from the encoded string + var jwe = JsonWebEncryption.fromCompactSerialization(encoded); + + // create a JsonWebKey for decrypting the signature + var jwk = JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ); + var keyStore = JsonWebKeyStore()..addKey(jwk); + + // decrypt the payload + var payload = await jwe.getPayload(keyStore); + print('decrypted content: ${payload.stringContent}'); +} + +// create a JWE +Future example4() async { + // create a builder + var builder = JsonWebEncryptionBuilder(); + + // set the content + builder.stringContent = 'This is my bigest secret'; + + // set some protected header + builder.setProtectedHeader('createdAt', DateTime.now().toIso8601String()); + + // add a key to encrypt the Content Encryption Key + var jwk = JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ); + builder.addRecipient(jwk, algorithm: 'RSA1_5'); + + // set the content encryption algorithm to use + builder.encryptionAlgorithm = 'A128CBC-HS256'; + + // build the jws + var jwe = builder.build(); + + // output the compact serialization + print('jwe compact serialization: ${jwe.toCompactSerialization()}'); + + // output the json serialization + print('jwe json serialization: ${jwe.toJson()}'); +} + +// decode and verify and validate a JWT +Future example5() async { + var encoded = 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.' + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + + // decode the jwt, note: this constructor can only be used for JWT inside JWS + // structures + var jwt = JsonWebToken.unverified(encoded); + + // output the claims + print('claims: ${jwt.claims}'); + + // create key store to verify the signature + var keyStore = JsonWebKeyStore() + ..addKey(JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + })); + + var verified = await jwt.verify(keyStore); + print('verified: $verified'); + + // alternatively, create and verify the JsonWebToken together, this is also + // applicable for JWT inside JWE + jwt = await JsonWebToken.decodeAndVerify(encoded, keyStore); + + // validate the claims + var violations = jwt.claims.validate(issuer: Uri.parse('alice')); + print('violations: $violations'); +} + +// create a JWT +Future example6() async { + var claims = JsonWebTokenClaims.fromJson({ + 'exp': + DateTime.now().add(Duration(hours: 4)).millisecondsSinceEpoch ~/ 1000, + 'iss': 'alice', + }); + + // create a builder, decoding the JWT in a JWS, so using a + // JsonWebSignatureBuilder + var builder = JsonWebSignatureBuilder(); + builder.setProtectedHeader('typ', 'JWT'); + + // set the content + builder.jsonContent = claims.toJson(); + + // add a key to sign, can only add one for JWT + builder.addRecipient( + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': base64Urlencode(' ** my secret ** '), + }), + algorithm: 'HS256'); + + // build the jws + var jws = builder.build(); + + // output the compact serialization + print('jwt compact serialization: ${jws.toCompactSerialization()}'); +} + +String base64Urlencode(String secret) { + var stringToBase64Url = utf8.fuse(base64Url); + return stringToBase64Url.encode(secret); +} + +// create a JWT, sign with RS512 +Future example7() async { + var claims = JsonWebTokenClaims.fromJson({ + 'exp': + DateTime.now().add(Duration(hours: 4)).millisecondsSinceEpoch ~/ 1000, + 'iss': 'alice', + }); + + // create a builder, decoding the JWT in a JWS, so using a + // JsonWebSignatureBuilder + var builder = JsonWebSignatureBuilder(); + builder.setProtectedHeader('typ', 'JWT'); + + // set the content + builder.jsonContent = claims.toJson(); + + // add a key to sign, can only add one for JWT + var key = JsonWebKey.fromPem(File('example/jwtRS512.key').readAsStringSync()); + builder.addRecipient(key, algorithm: 'RS512'); + + // build the jws + var jws = builder.build(); + + // output the compact serialization + print('jwt compact serialization: ${jws.toCompactSerialization()}'); +} + +// generate a key for use with ES256 signing +Future example8() async { + var alg = JsonWebAlgorithm.getByName('ES256'); + + var key = alg.generateRandomKey(); + print(JsonEncoder.withIndent(' ').convert(key)); + + final hash = utf8.encode('TEST'); + + var sig = key.sign(hash); + final valid = key.verify(hash, sig); + + print('valid? $valid'); +} + +// decode and decrypt a JWE with compression +Future example9() async { + var encoded = + 'eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwiemlwIjoiREVGIn0.pe4pvJvfuUdntcPNywk1lv3naOcVtw4AzM0BQt00UDtrmGdMVP9k2u8VbdjWM92GWg6RWdP4mBGsZTzF-ouO43ri8Pf7OmCGSn7kieRJ0en8anW03Z6YCdwmJcJGVadWh6astPOKHQKJ9NJUXDgYbub6-xCvxLvxO8ULsZViDEHkC8pXTu6-IXZDnwMc0YHIgt6mMTlvqae9h9EE97mNKoK3Dk2E3pju-ndOmhSswB-q_vKd4GhNbZfJpNGNuqKxj1MdcBWQbRR6CM6rrCkW5UV0MTGPdvYNrt64UdyjkcI_OlO_Xdau2vP9lCPW-_I0dPbSBm--d0-IioRWx7Rubw.PT70GBmWf4Y5X1oJHZ7DGw.OSRi9i-ePL7uZJxJP4H-L-LXF62wfgyYlqJBftR05yA.tXIsIKasRxqeyqkX6tCyy_HHkb2OywyU0X-abxW0Czo'; + + // create a JsonWebEncryption from the encoded string + var jwe = JsonWebEncryption.fromCompactSerialization(encoded); + + // create a JsonWebKey for decrypting the signature + var jwk = JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ); + var keyStore = JsonWebKeyStore()..addKey(jwk); + + // decrypt the payload + var payload = await jwe.getPayload(keyStore); + print('decrypted content: ${payload.stringContent}'); +} + +// create a JWE with compression +Future example10() async { + // create a builder + var builder = JsonWebEncryptionBuilder(); + + // set the content + builder.stringContent = 'This is my bigest secret'; + + // set some protected header + builder.setProtectedHeader('createdAt', DateTime.now().toIso8601String()); + + // add a key to encrypt the Content Encryption Key + var jwk = JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ); + builder.addRecipient(jwk, algorithm: 'RSA1_5'); + + // set the content encryption algorithm to use + builder.encryptionAlgorithm = 'A128CBC-HS256'; + builder.compressionAlgorithm = 'DEF'; + + // build the jws + var jwe = builder.build(); + + // output the compact serialization + print('jwe compact serialization: ${jwe.toCompactSerialization()}'); + + // output the json serialization + print('jwe json serialization: ${jwe.toJson()}'); +} diff --git a/packages/jose_plus/example/jwtRS512.key b/packages/jose_plus/example/jwtRS512.key new file mode 100644 index 00000000..c9ef43ca --- /dev/null +++ b/packages/jose_plus/example/jwtRS512.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEArvhnCMdf0RaXU0FODFwf8s51hK9yMf9c3HP11fuwQxuXtdoY +BLxzZW7ViepldG6hyWRmOVn9O1oZ1WrXtNbUF+SdByswmCw6GAQ1vViDMkQZsRFi +Pc5Zve1FG0GyAsuTo6CJ2CsZVIuP+Gh2IeC2kPdIX1QboT85980X4PUgKOMvsxF1 +FIq08xZm4O8x3DvbnRp01/0gzSQQ4rRQ7PONruZqVTsqIZ8tbfNR7NJEYV4OkHWW +2RBw/385udOe08gVrpufnhvq1lBmPXcIcwYhmmCgUcDhrj1RkDLEjyaJUTcuxa9i +unSCWlzHX8Ei+1pKX8oMBiZW+7gM82bVC1h8HRCmrike5JVO1TbS8jbce8MfbWKI +d3xit9k0PO6xgB7+jTMxfXg2uqXExEECaChaSjO9gTgL2UMGtsgMnVWUScmgJbH8 +cUnzz4iKIlLleiKTvV2VdO22DnRZrkC1xt4NBNg8YIWbsY5LzYiZzgdGxlnuY9rR +Z1qalZ3Mn5urEADwdHRqT+HpYdUB6uuvdvYJEUkBWz/uWFba74aAaBBT1+HlHGxz +E4yo+RXZAXqfnCyGM06ENpWfdVjIzpFtmhk+bI/at09qhiLD26zatophp3snC2Cw +ZOPvbliO3RO7//c/NE34RPkciG43j9cid33vdspAlo8k7p6n7irMePatVQECAwEA +AQKCAgArMoCdljt9r4okr9bJDmWE2JwC6h3TYixW8PocnkriBonqB7IuWShUPozR +w+DQYg/nXLYwKptub8w4+GvQ5cjvLS3ney5AI4skKsXpvVSGsHF3WcdbYvqc/Vs2 +Y33DWudUWmotyFY0nVAqEAA6VPQ9zg88GdPId4ekOipPC7Wo4o4AM6soKyaJbVpw +5hz1NoH9w25ddM0IWIMitUnVdjvgllpiOH0qV9Yrcwsi4LapI0ONPrrDVF/RdGOY +ETsBJkDUHu/FZTcROkBDpIOdgkIBjyGFjY0qK1FbvGzyupzkRC9z9tz1E5arIITI +izJLw/rgs01Mp2KKOuJXpWerqWjai3pGF2O3dweDn0H2aHvRy5kxG8ZNmuYrlupN +1uKAu6oIBDj2v9rNrr+iEg6Rbv0Tyzvgukg2AvEkkvPnelUgzG2TLsLC2hHPWg+L ++n+YsEm69+eNIgCOpKTsZblcBEyyZTklhj9aYzhf5VLOb3G6CXicuETRwJRUCvxy +UFXMaxU2qSJb9zHHZUeVffH6BONByqaTeMY/ihYL0tA6dJz1Hy0JVV9yPyRG74PZ +z0xYVpyD7xc+sbqvYyiIC6IOV5YDUN8FeBCmcN9SZyDYZ/NVtUQm8rNu6OIeoaqp +yUhvFL9OTsbZV7u9Nxmt1zjqmjgL0FPyzPOvhCZXO0zKBliB0QKCAQEA3/Dqc2PB +8Nc58pjISZxonatDZDsbyWhp60jNu0VdNZAzS6iib5zev3lGOzdGXI572MNDa/ZA +dpFslQVGLWmdOVhHkXx+snhhflxVKGuVf1Ta0TmgrBdSkbpB+as9vMhPPSkHu0wF +79ldQcxA91CjLB029ccVdTqH8nUE2Du9g7LfxFRi/pOes8M02PHFM2SRD/4j+Ajz +mmX2UAT2FGVzGeQcSTRMqApLnHc+hIykKgpwUTbadhK2uyV1INF+qOymvgSx3F5f +Ey+9O0IECgm6T3GztZboNJh8yQwm9kjhnK+I984SXyXk6eIXIDW/AZkjxBibxyyS +WSmqhuBq8D4tVwKCAQEAyATJUPKoMzKk16nHCD0w1mxOHZNlbSy0oO5108nvbKWL +G1oaHmkqisJ19VQYZ6J/jo/7P1ElcCJj5ItB64b2NMrA4fDDvtY5xBK+xGY5MTnF +OFXtSzaNORS+ESe/Rzrba+7pqobY9Ko1lOu+Q8qyysrwQDbHhroKAms0CNwqafml +KzpcoUxKlMMAqmMxbiIFmoiX7pHI+qTaMCcP8ArYiG24pbuWPzVVYWo4oImz7aXv +O+gyhxEwUK5fxB3hMBOT92Rh3UZGTREPmHea4d/XxRbNdypsOZuOM6jBki5RmS22 +4qVar2akvN4YcHEG6ciXHbP8ifjLyZv0weuXP0pBZwKCAQEArsKLtFD9YL4VcsTY +SNnOqnyksuuN27eoMP030BVpkAtgfIK1gPSiwDocHMoeFsz6yUZYr0AVPnJukjzz +by0kjUPfViIZnZ1kOABGVKzgleXafbzDLolk3mTEZ9rC6zhG3ivO5NOHLo4qwNZt +PJOpQxvpcBRVIo7y2LuJdFXqp4RILCbOumzpRM/pxgOnKVwp+JkT6NTThoJbnkbA +41noYSQrDtXJnLmsGAbWuwomcsiCrl5oTgx8nb+1kE7s0VcdKNzxEFB1UbvHqR7z +mAHPa4AIBoYl4CW//AWHwltN9cDm2rvuimJAKmVONsy6qryftMCfkOevrfVeJrOr +wKBFMQKCAQEAwPZlJTPEcwgDoY9H1Tn7OJFp1vyQ1/9ODweeIoDo+17FUdDd68K3 +aQBrfg7wMGLDPkzHLXFidcinqB90q/0a3xlPdWmp3hybw/31jlG0ETpTeqLi9zGc +kpnhN3mp/dTZREpLoNjQy1C9unqnpo0RabTYjIqMePxXlbfIjlegj3ZpEsgN/woz +1Uo/BSIG2OH3l/r44A5C3xNU7ylwJbrA5PU9Mp5DN52DTiSEltPiAn2Xy6qZvFYn +z146Q2A0FAZ/NCbihu85+TVhX0zb3dbuOVRjSfhiSj04tbuzawxGakmA6yxszVRv +z0lQOIHCEp4Ss3QlSRH9v/U17jMTSdqgNwKCAQB3qZdt4L4UC6Ln6n5dAlMclALU +uISG6VjkpfVyq/Qoz/CyunDPuNhAwEtCCLhrOOv0VXgmB2A+YcdMusQbOM33Xlc8 ++0ny+J81JOLwDhGT/g0YwCCSraAdxU6dkTNyD25hFOUMr6rbQPmfV4SXh4K1zN6A +xD+4LmK8LO35zk6xKSMk3b118dEdimBA6H0Fyzf0hS3jNF8zjHZQY6d7KFWzm0UF +yX2sHW7cOoq5o8A1kUPAifi6qETeEP9v8I5q3a5oTQvMDqWxxbHLYHjmdcgt2cVY +UzhdQTPSu98tfRH2Bc1LIi/QsAqlY+vTPyLtP0xWzGjzeQmCcmu7Lla0wECV +-----END RSA PRIVATE KEY----- diff --git a/packages/jose_plus/example/jwtRS512.key.pub b/packages/jose_plus/example/jwtRS512.key.pub new file mode 100644 index 00000000..e718dae9 --- /dev/null +++ b/packages/jose_plus/example/jwtRS512.key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCu+GcIx1/RFpdTQU4MXB/yznWEr3Ix/1zcc/XV+7BDG5e12hgEvHNlbtWJ6mV0bqHJZGY5Wf07WhnVate01tQX5J0HKzCYLDoYBDW9WIMyRBmxEWI9zlm97UUbQbICy5OjoInYKxlUi4/4aHYh4LaQ90hfVBuhPzn3zRfg9SAo4y+zEXUUirTzFmbg7zHcO9udGnTX/SDNJBDitFDs842u5mpVOyohny1t81Hs0kRhXg6QdZbZEHD/fzm5057TyBWum5+eG+rWUGY9dwhzBiGaYKBRwOGuPVGQMsSPJolRNy7Fr2K6dIJaXMdfwSL7WkpfygwGJlb7uAzzZtULWHwdEKauKR7klU7VNtLyNtx7wx9tYoh3fGK32TQ87rGAHv6NMzF9eDa6pcTEQQJoKFpKM72BOAvZQwa2yAydVZRJyaAlsfxxSfPPiIoiUuV6IpO9XZV07bYOdFmuQLXG3g0E2DxghZuxjkvNiJnOB0bGWe5j2tFnWpqVncyfm6sQAPB0dGpP4elh1QHq66929gkRSQFbP+5YVtrvhoBoEFPX4eUcbHMTjKj5FdkBep+cLIYzToQ2lZ91WMjOkW2aGT5sj9q3T2qGIsPbrNq2imGneycLYLBk4+9uWI7dE7v/9z80TfhE+RyIbjeP1yJ3fe92ykCWjyTunqfuKsx49q1VAQ== rbellens@madrid-2.local diff --git a/packages/jose_plus/lib/jose.dart b/packages/jose_plus/lib/jose.dart new file mode 100644 index 00000000..b7811c2c --- /dev/null +++ b/packages/jose_plus/lib/jose.dart @@ -0,0 +1,10 @@ +/// Javascript Object Signing and Encryption (JOSE) library +/// +library; + +export 'src/jose.dart'; +export 'src/jwk.dart'; +export 'src/jws.dart'; +export 'src/jwe.dart'; +export 'src/jwt.dart'; +export 'src/jwa.dart'; diff --git a/packages/jose_plus/lib/jose_plus.dart b/packages/jose_plus/lib/jose_plus.dart new file mode 100644 index 00000000..42e484c8 --- /dev/null +++ b/packages/jose_plus/lib/jose_plus.dart @@ -0,0 +1,7 @@ +/// Javascript Object Signing and Encryption (JOSE) library +/// +/// This is the package-name-matching entrypoint; the original `jose.dart` +/// entrypoint is kept for backwards compatibility. +library; + +export 'jose.dart'; diff --git a/packages/jose_plus/lib/src/ecdh.dart b/packages/jose_plus/lib/src/ecdh.dart new file mode 100644 index 00000000..49669842 --- /dev/null +++ b/packages/jose_plus/lib/src/ecdh.dart @@ -0,0 +1,410 @@ +/// ECDH-ES key agreement per [RFC 7518 §4.6](https://tools.ietf.org/html/rfc7518#section-4.6) +library; + +import 'dart:typed_data'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; + +import 'jwk.dart'; +import 'util.dart'; + +// --------------------------------------------------------------------------- +// EC point arithmetic for Weierstrass curves y² = x³ + ax + b (mod p) +// --------------------------------------------------------------------------- + +class _ECPoint { + final BigInt x; + final BigInt y; + final bool isInfinity; + + const _ECPoint(this.x, this.y) : isInfinity = false; + _ECPoint.infinity() + : x = BigInt.zero, + y = BigInt.zero, + isInfinity = true; + + _ECPoint add(_ECPoint other, BigInt p, BigInt a) { + if (isInfinity) return other; + if (other.isInfinity) return this; + if (x == other.x && y == other.y) return double_(p, a); + if (x == other.x) return _ECPoint.infinity(); + + final lambda = ((other.y - y) * (other.x - x).modInverse(p)) % p; + final rx = (lambda * lambda - x - other.x) % p; + final ry = (lambda * (x - rx) - y) % p; + return _ECPoint(rx, ry); + } + + _ECPoint double_(BigInt p, BigInt a) { + if (isInfinity || y == BigInt.zero) return _ECPoint.infinity(); + + final lambda = + ((BigInt.from(3) * x * x + a) * (BigInt.from(2) * y).modInverse(p)) % p; + final rx = (lambda * lambda - BigInt.from(2) * x) % p; + final ry = (lambda * (x - rx) - y) % p; + return _ECPoint(rx, ry); + } + + /// Scalar multiplication using double-and-add. + _ECPoint multiply(BigInt k, BigInt p, BigInt a) { + var result = _ECPoint.infinity(); + var base = this; + var n = k; + while (n > BigInt.zero) { + if (n.isOdd) { + result = result.add(base, p, a); + } + base = base.double_(p, a); + n >>= 1; + } + return result; + } +} + +class _ECCurve { + final BigInt p; + final BigInt a; + final BigInt b; + final int fieldSize; // in bytes + + const _ECCurve(this.p, this.a, this.b, this.fieldSize); +} + +// SEC 2 curve parameters +// https://www.secg.org/sec2-v2.pdf + +final _p256 = _ECCurve( + BigInt.parse( + 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF', + radix: 16), + BigInt.parse( + 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC', + radix: 16), + BigInt.parse( + '5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B', + radix: 16), + 32, +); + +final _p384 = _ECCurve( + BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE' + 'FFFFFFFF0000000000000000FFFFFFFF', + radix: 16), + BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE' + 'FFFFFFFF0000000000000000FFFFFFFC', + radix: 16), + BigInt.parse( + 'B3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875A' + 'C656398D8A2ED19D2A85C8EDD3EC2AEF', + radix: 16), + 48, +); + +final _p521 = _ECCurve( + BigInt.parse( + '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFF', + radix: 16), + BigInt.parse( + '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' + 'FFFC', + radix: 16), + BigInt.parse( + '0051953EB9618E1C9A1F929A21A0B68540EEA2DA725B99B315F3B8B489918EF1' + '09E156193951EC7E937B1652C0BD3BB1BF073573DF883D2C34F1EF451FD46B50' + '3F00', + radix: 16), + 66, +); + +_ECCurve _curveForName(String name) { + switch (name) { + case 'P-256': + return _p256; + case 'P-384': + return _p384; + case 'P-521': + return _p521; + default: + throw UnsupportedError('Unsupported curve: $name'); + } +} + +/// Returns the key length in bits for a content/wrapping algorithm. +int _keyLengthForAlgorithm(String algorithm) { + switch (algorithm) { + case 'A128CBC-HS256': + return 256; + case 'A192CBC-HS384': + return 384; + case 'A256CBC-HS512': + return 512; + case 'A128GCM': + return 128; + case 'A192GCM': + return 192; + case 'A256GCM': + return 256; + case 'A128KW': + return 128; + case 'A192KW': + return 192; + case 'A256KW': + return 256; + default: + throw UnsupportedError('Unsupported algorithm: $algorithm'); + } +} + +// --------------------------------------------------------------------------- +// ECDH shared secret Z = x(d · Q) +// --------------------------------------------------------------------------- + +BigInt _ecdhAgreement(EcPrivateKey privateKey, EcPublicKey publicKey) { + final curveName = privateKey.curve.name.split('/').last; + final curve = _curveForName(curveName); + + final q = _ECPoint(publicKey.xCoordinate, publicKey.yCoordinate); + + // Validate that the public key point lies on the curve: y² ≡ x³ + ax + b (mod p) + final lhs = (q.y * q.y) % curve.p; + final rhs = (q.x * q.x * q.x + curve.a * q.x + curve.b) % curve.p; + if (lhs != rhs) { + throw ArgumentError('Public key point is not on the curve'); + } + + final result = q.multiply(privateKey.eccPrivateKey, curve.p, curve.a); + + if (result.isInfinity) { + throw StateError('ECDH produced point at infinity'); + } + return result.x; +} + +int _fieldSizeForCurve(String curveName) => _curveForName(curveName).fieldSize; + +// --------------------------------------------------------------------------- +// Concat KDF (NIST SP 800-56A, RFC 7518 §4.6.2) +// --------------------------------------------------------------------------- + +Uint8List concatKdf( + Uint8List sharedSecret, { + required int keyDataLen, + required String algorithmId, + Uint8List? apu, + Uint8List? apv, +}) { + final keyDataBytes = keyDataLen ~/ 8; + final hashLen = 32; // SHA-256 + final reps = (keyDataBytes + hashLen - 1) ~/ hashLen; + + final result = BytesBuilder(); + + for (var counter = 1; counter <= reps; counter++) { + final input = BytesBuilder(); + + // counter (32-bit big-endian) + input.add(_int32BigEndian(counter)); + + // Z + input.add(sharedSecret); + + // AlgorithmID + final algIdBytes = Uint8List.fromList(algorithmId.codeUnits); + input.add(_int32BigEndian(algIdBytes.length)); + input.add(algIdBytes); + + // PartyUInfo + final apuBytes = apu ?? Uint8List(0); + input.add(_int32BigEndian(apuBytes.length)); + if (apuBytes.isNotEmpty) input.add(apuBytes); + + // PartyVInfo + final apvBytes = apv ?? Uint8List(0); + input.add(_int32BigEndian(apvBytes.length)); + if (apvBytes.isNotEmpty) input.add(apvBytes); + + // SuppPubInfo + input.add(_int32BigEndian(keyDataLen)); + + final inputBytes = Uint8List.fromList(input.takeBytes()); + final digest = algorithms.digest.sha256.createAlgorithm(); + digest.update(inputBytes, 0, inputBytes.length); + final hash = Uint8List(digest.digestSize); + digest.doFinal(hash, 0); + result.add(hash); + } + + return Uint8List.fromList(result.takeBytes().sublist(0, keyDataBytes)); +} + +Uint8List _int32BigEndian(int value) { + final bytes = Uint8List(4); + ByteData.view(bytes.buffer).setUint32(0, value, Endian.big); + return bytes; +} + +Uint8List _bigIntToBytes(BigInt value, int fieldSize) { + var hex = value.toRadixString(16); + if (hex.length % 2 != 0) hex = '0$hex'; + final raw = Uint8List(hex.length ~/ 2); + for (var i = 0; i < raw.length; i++) { + raw[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + if (raw.length > fieldSize) { + throw StateError( + 'EC coordinate exceeds field size: ${raw.length} > $fieldSize'); + } + if (raw.length < fieldSize) { + final padded = Uint8List(fieldSize); + padded.setRange(fieldSize - raw.length, fieldSize, raw); + return padded; + } + return raw; +} + +// --------------------------------------------------------------------------- +// AES Key Wrap / Unwrap (RFC 3394) +// --------------------------------------------------------------------------- + +Uint8List _aesKeyWrap(Uint8List kek, Uint8List plaintext) { + if (plaintext.length % 8 != 0) { + throw ArgumentError('Plaintext length must be a multiple of 8 bytes'); + } + + final wrapper = SymmetricKey(keyValue: kek) + .createEncrypter(algorithms.encryption.aes.keyWrap); + return Uint8List.fromList(wrapper.encrypt(plaintext).data); +} + +Uint8List _aesKeyUnwrap(Uint8List kek, Uint8List ciphertext) { + if (ciphertext.length % 8 != 0 || ciphertext.length < 24) { + throw ArgumentError( + 'Ciphertext length must be at least 24 and a multiple of 8 bytes'); + } + + final wrapper = SymmetricKey(keyValue: kek) + .createEncrypter(algorithms.encryption.aes.keyWrap); + return Uint8List.fromList(wrapper.decrypt(EncryptionResult(ciphertext))); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +class EcdhEsResult { + final Uint8List derivedKey; + final JsonWebKey ephemeralPublicKey; + + EcdhEsResult({required this.derivedKey, required this.ephemeralPublicKey}); +} + +/// Sender side: generate ephemeral key, perform ECDH, derive key material. +EcdhEsResult ecdhEsDerive({ + required JsonWebKey recipientPublicKey, + required String algorithmId, + required int keyDataLen, + Uint8List? apu, + Uint8List? apv, + KeyPair? ephemeralKeyPair, +}) { + final recipientPublic = recipientPublicKey.cryptoKeyPair.publicKey; + if (recipientPublic is! EcPublicKey) { + throw ArgumentError('Recipient key must be an EC public key'); + } + + final curveName = recipientPublicKey['crv'] as String; + final curveId = curvesByName[curveName]; + if (curveId == null) { + throw UnsupportedError('Unsupported curve: $curveName'); + } + + final ephemeral = ephemeralKeyPair ?? KeyPair.generateEc(curveId); + final ephemeralPrivate = ephemeral.privateKey as EcPrivateKey; + final ephemeralPublic = ephemeral.publicKey as EcPublicKey; + + final z = _ecdhAgreement(ephemeralPrivate, recipientPublic); + final fieldSize = _fieldSizeForCurve(curveName); + final zBytes = _bigIntToBytes(z, fieldSize); + + final derivedKey = concatKdf( + zBytes, + keyDataLen: keyDataLen, + algorithmId: algorithmId, + apu: apu, + apv: apv, + ); + + final epk = JsonWebKey.fromCryptoKeys(publicKey: ephemeralPublic); + + return EcdhEsResult(derivedKey: derivedKey, ephemeralPublicKey: epk); +} + +/// Recipient side: use private key + EPK to derive key material. +Uint8List ecdhEsDecrypt({ + required JsonWebKey recipientPrivateKey, + required JsonWebKey ephemeralPublicKey, + required String algorithmId, + required int keyDataLen, + Uint8List? apu, + Uint8List? apv, +}) { + final recipientPrivate = recipientPrivateKey.cryptoKeyPair.privateKey; + if (recipientPrivate is! EcPrivateKey) { + throw ArgumentError('Recipient key must have an EC private key'); + } + + final ephemeralPublic = ephemeralPublicKey.cryptoKeyPair.publicKey; + if (ephemeralPublic is! EcPublicKey) { + throw ArgumentError('Ephemeral key must be an EC public key'); + } + + // Verify curve of EPK matches recipient key + final recipientCurve = recipientPrivateKey['crv'] as String; + final ephemeralCurve = ephemeralPublicKey['crv'] as String; + if (recipientCurve != ephemeralCurve) { + throw ArgumentError('Ephemeral key curve ($ephemeralCurve) does not match ' + 'recipient key curve ($recipientCurve)'); + } + + final z = _ecdhAgreement(recipientPrivate, ephemeralPublic); + final curveName = recipientCurve; + final fieldSize = _fieldSizeForCurve(curveName); + final zBytes = _bigIntToBytes(z, fieldSize); + + return concatKdf( + zBytes, + keyDataLen: keyDataLen, + algorithmId: algorithmId, + apu: apu, + apv: apv, + ); +} + +// --------------------------------------------------------------------------- +// Algorithm helpers +// --------------------------------------------------------------------------- + +String ecdhAlgorithmId(String algorithm, String encAlgorithm) { + if (algorithm == 'ECDH-ES') return encAlgorithm; + return algorithm; +} + +int ecdhKeyDataLen(String algorithm, String encAlgorithm) { + if (algorithm == 'ECDH-ES') return _keyLengthForAlgorithm(encAlgorithm); + final wrapAlg = algorithm.substring('ECDH-ES+'.length); + return _keyLengthForAlgorithm(wrapAlg); +} + +List ecdhEsWrapKey(Uint8List derivedKey, JsonWebKey cek) { + final cekBytes = Uint8List.fromList(decodeBase64EncodedBytes(cek['k'])); + return _aesKeyWrap(derivedKey, cekBytes); +} + +Uint8List ecdhEsUnwrapKey(Uint8List derivedKey, List encryptedKey) { + return _aesKeyUnwrap(derivedKey, Uint8List.fromList(encryptedKey)); +} diff --git a/packages/jose_plus/lib/src/jose.dart b/packages/jose_plus/lib/src/jose.dart new file mode 100644 index 00000000..63b0c3b5 --- /dev/null +++ b/packages/jose_plus/lib/src/jose.dart @@ -0,0 +1,418 @@ +/// [JSON Object Signing and Encryption](https://tools.ietf.org/html/rfc7515) +library; + +import 'util.dart'; +import 'jws.dart'; +import 'jwe.dart'; +import 'jwk.dart'; +import 'dart:async'; +import 'dart:convert' as convert; +import 'package:meta/meta.dart'; + +/// Contains the `JSON Object Signing and Encryption` header parameters for +/// [JsonWebSignature] and [JsonWebEncryption] +class JoseHeader extends JsonObject { + /// Constructs a [JoseHeader] from a [json] map + JoseHeader.fromJson(Map super.json) : super.from(); + + /// Constructs a [JoseHeader] from a base64 [encodedString] representation of + /// the json string + JoseHeader.fromBase64EncodedString(super.encodedString) : super.decode(); + + /// Identifies the cryptographic algorithm used to secure a [JsonWebSignature] + /// or to encrypt or determine the value of a Content Encryption Key with + /// [JsonWebEncryption]. + String? get algorithm => getTyped('alg'); + + /// Refers to a resource for a set of JSON-encoded public keys, one of which + /// corresponds to the key used to digitally sign the [JsonWebSignature] or + /// encrypt the [JsonWebEncryption]. + Uri? get jwkSetUrl => getTyped('jku'); + + /// The public key that corresponds to the key used to digitally sign the + /// [JsonWebSignature] or encrypt the [JsonWebEncryption]. + JsonWebKey? get jsonWebKey => + getTyped('jwk', factory: (v) => JsonWebKey.fromJson(v)); + + /// A hint indicating which key was used to secure the [JsonWebSignature] or + /// encrypt the [JsonWebEncryption]. + String? get keyId => getTyped('kid'); + +/* + TODO: implement X.509 + + /// Refers to a resource for the X.509 public key certificate or certificate + /// chain corresponding to the key used to digitally sign the + /// [JsonWebSignature] or encrypt the [JsonWebEncryption]. + Uri get x509Url => this['x5u'] == null ? null : Uri.parse(this['x5u']); + + /// The X.509 public key certificate or certificate chain [RFC5280] + /// corresponding to the key used to digitally sign the + /// [JsonWebSignature] or encrypt the [JsonWebEncryption]. + dynamic get x509CertificateChain => this['x5c']; + + /// A base64url-encoded SHA-1 thumbprint (a.k.a. digest) of the DER + /// encoding of the X.509 certificate corresponding to the key used to + /// digitally sign the [JsonWebSignature] or encrypt the [JsonWebEncryption]. + String get x509CertificateSha1Thumbprint => this['x5t']; + + /// A base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER + /// encoding of the X.509 certificate corresponding to the key used to + /// digitally sign the [JsonWebSignature] or encrypt the [JsonWebEncryption]. + String get x509CertificateSha256Thumbprint => this['x5t#S256']; +*/ + + /// Declares the media type [IANA.MediaTypes](https://www.iana.org/assignments/media-types/media-types.xhtml) + /// of the complete [JsonWebSignature] or [JsonWebEncryption]. + String? get type => getTyped('typ'); + + /// Declares the media type [IANA.MediaTypes](https://www.iana.org/assignments/media-types/media-types.xhtml) + /// the secured content (the payload) of the [JsonWebSignature] or + /// [JsonWebEncryption]. + String? get contentType => getTyped('cty'); + + /// Indicates that extensions to this specification and/or [JsonWebAlgorithm] + /// are being used that MUST be understood and processed. + List? get critical => getTypedList('crit'); + + /// The content encryption algorithm used to perform authenticated encryption + /// on the plaintext to produce the ciphertext and the Authentication Tag. + /// + /// Only for [JsonWebEncryption] objects + String? get encryptionAlgorithm => getTyped('enc'); + + /// Compression algorithm applied to the plaintext before encryption, if any. + /// + /// Only for [JsonWebEncryption] objects + String? get compressionAlgorithm => getTyped('zip'); + + /// The ephemeral public key for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + JsonWebKey? get ephemeralPublicKey => + getTyped('epk', factory: (v) => JsonWebKey.fromJson(v)); + + /// Agreement PartyUInfo value for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + String? get agreementPartyUInfo => getTyped('apu'); + + /// Agreement PartyVInfo value for ECDH key agreement. + /// + /// Only for [JsonWebEncryption] objects using ECDH-ES algorithms. + String? get agreementPartyVInfo => getTyped('apv'); +} + +/// Base class for [JsonWebSignature] and [JsonWebEncryption]. +abstract class JoseObject { + /// The binary data contained in this object + /// + /// In case of a [JsonWebSignature], this is the plain content, in case of a + /// [JsonWebEncryption], this is the ciphertext. + final List data; + + /// The per-recipient content of this object + final List recipients; + + /// Header parameters that are integrity protected and shared by all + /// recipients + /// + /// [JsonWebSignature] objects do not have a shared protected header + final JsonObject? sharedProtectedHeader; + + /// Header parameters that are not integrity protected and are shared by all + /// recipients + /// + /// [JsonWebSignature] objects do not have a shared unprotected header + final JsonObject? sharedUnprotectedHeader; + + JoseObject(this.data, this.recipients, + {this.sharedUnprotectedHeader, this.sharedProtectedHeader}); + + /// Constructs a [JsonWebSignature] or [JsonWebEncryption] from its json + /// representation. + factory JoseObject.fromJson(Map json) { + if (json.containsKey('payload')) return JsonWebSignature.fromJson(json); + if (json.containsKey('ciphertext')) return JsonWebEncryption.fromJson(json); + throw ArgumentError.value( + json, 'json', 'Not a valid `JsonWebSignature` or `JsonWebEncryption`'); + } + + /// Constructs a [JsonWebSignature] or [JsonWebEncryption] from its compact + /// serialization. + factory JoseObject.fromCompactSerialization(String serialization) { + var parts = serialization.split('.'); + switch (parts.length) { + case 3: + return JsonWebSignature.fromCompactSerialization(serialization); + case 5: + return JsonWebEncryption.fromCompactSerialization(serialization); + default: + throw ArgumentError.value(serialization, 'serialization', + 'Not a valid `JsonWebSignature` or `JsonWebEncryption`'); + } + } + + /// Serializes the [JsonWebSignature] or [JsonWebEncryption] to a string. + /// + /// Throws an exception when object cannot be serialized to its compact form, + /// i.e. when the [JsonWebSignature] has multiple signatures or the + /// [JsonWebEncryption] has multiple recipients. + String toCompactSerialization(); + + /// Serializes the [JsonWebSignature] or [JsonWebEncryption] to a JSON + /// representation. + /// + /// For [JsonWebSignature], returns a flattened JSON serialization when it + /// contains only one signature and a general JSON serialization otherwise. + Map toJson(); + + /// The JOSE header + /// + /// In case of multiple recipients, this header is composed of the shared + /// header parameters and the per-recipient header parameters that are common. + /// In case of a single recipient, this contains all header parameters. + JoseHeader get commonHeader { + var sharedHeader = safeUnion( + [sharedProtectedHeader?.toJson(), sharedUnprotectedHeader?.toJson()]); + return JoseHeader.fromJson(commonUnion(recipients.map((r) => safeUnion([ + sharedHeader, + r.protectedHeader?.toJson(), + r.unprotectedHeader?.toJson() + ])))); + } + + /// The JOSE header parameters that are integrity protected + /// + /// In case of multiple recipients, this header is composed of the shared + /// protected header parameters and the per-recipient protected header + /// parameters that are common. + /// In case of a single recipient, this contains all protected header + /// parameters. + JoseHeader get commonProtectedHeader { + var sharedHeader = sharedProtectedHeader?.toJson(); + return JoseHeader.fromJson(commonUnion(recipients.map((r) => safeUnion([ + sharedHeader, + r.protectedHeader?.toJson(), + ])))); + } + + Future verify(JsonWebKeyStore keyStore) async { + try { + await getPayload(keyStore); + return true; + } catch (e) { + return false; + } + } + + /// Returns a future that resolves to the payload if the content of this + /// object can be decrypted and verified. Otherwise the future fails with a + /// [JoseException] + /// + /// This method will fail if none of the signatures or recipients use one of + /// the algorithms listed in [allowedAlgorithms] for signing the payload or + /// wrapping the key. By default, all algorithms are allowed except `none`. + Future getPayload( + JsonWebKeyStore keyStore, { + List? allowedAlgorithms, + }) async { + for (var r in recipients) { + var header = _headerFor(r); + + if (allowedAlgorithms != null && + !allowedAlgorithms.contains(header.algorithm)) { + continue; + } + + if (allowedAlgorithms == null && header.algorithm == 'none') { + continue; + } + + await for (var key in keyStore.findJsonWebKeys( + header, + this is JsonWebSignature + ? 'verify' + : header.algorithm == 'dir' + ? 'decrypt' + : 'unwrapKey', + )) { + try { + var payload = getPayloadFor(key, header, r); + + if (payload != null) { + return JosePayload(payload, _protectedHeaderFor(r)); + } + } catch (e) { + // ignore + } + } + } + + throw JoseException('Could not decrypt/verify payload'); + } + + @protected + List? getPayloadFor( + JsonWebKey? key, + JoseHeader header, + JoseRecipient recipient, + ); + + JoseHeader _headerFor(JoseRecipient recipient) { + return JoseHeader.fromJson(safeUnion([ + sharedProtectedHeader?.toJson(), + sharedUnprotectedHeader?.toJson(), + recipient.header.toJson() + ])); + } + + JoseHeader _protectedHeaderFor(JoseRecipient recipient) { + return JoseHeader.fromJson(safeUnion([ + sharedProtectedHeader?.toJson(), + recipient.protectedHeader?.toJson() + ])); + } +} + +/// Per-recipient content of a [JoseObject] +/// +/// Contains a signature for [JsonWebSignature] objects or an encrypted Content +/// Encryption Key for [JsonWebEncryption] +abstract class JoseRecipient { + /// Per-recipient protected header + /// + /// These Header Parameter values are integrity protected. + /// + /// [JsonWebEncryption] objects do not have a per-recipient protected header + final JsonObject? protectedHeader; + + /// Per-recipient unprotected header + /// + /// These Header Parameter values are not integrity protected. + final JsonObject? unprotectedHeader; + + /// Per-recipient combined protected and unprotected header + final JoseHeader header; + + /// Per-recipient binary data + /// + /// For [JsonWebSignature] objects, this is the signature, for + /// [JsonWebEncryption] objects, this is the encrypted Content Encryption Key + /// + /// For [JsonWebEncryption] with direct encryption, this will be an empty list. + final List data; + + JoseRecipient( + {required this.data, this.protectedHeader, this.unprotectedHeader}) + : header = JoseHeader.fromJson(safeUnion( + [protectedHeader?.toJson(), unprotectedHeader?.toJson()])); + + Map toJson(); +} + +/// The payload of a [JsonWebSignature] or [JsonWebEncryption] +/// +/// Contains the data as well as protected header fields +class JosePayload { + /// The binary data of the payload + final List data; + + /// The protected header parameters + final JsonObject? protectedHeader; + + JosePayload(this.data, [this.protectedHeader]); + + /// Returns the data as string + String get stringContent => convert.utf8.decode(data); + + /// Return the data as json + dynamic get jsonContent => convert.json.decode(stringContent); + + /// The media type [IANA.MediaTypes](https://www.iana.org/assignments/media-types/media-types.xhtml) + /// of the payload. + /// + /// This is the 'cty' header parameter + String? get mediaType => protectedHeader!['cty']; +} + +/// Base class for [JsonWebSignatureBuilder] and [JsonWebEncryptionBuilder] +abstract class JoseObjectBuilder { + final Map _protectedHeaderParameters = {}; + + /// The binary payload + List? data; + + @protected + final List> recipients = []; // TODO + + JoseObjectBuilder(); + + /// Sets the payload from a string value + set stringContent(String v) { + data = convert.utf8.encode(v); + } + + /// Sets the payload from a json value + set jsonContent(dynamic v) { + stringContent = convert.json.encode(v); + } + + /// Sets the + set content(dynamic v) { + if (v is String) { + stringContent = v; + } else if (v is List) { + data = v; + } else { + jsonContent = v; + } + } + + /// Sets a shared protected header parameter + /// + /// Protected header parameters are integrity protected by the signing or + /// authenticated encryption algorithm. In case of non json payload, protected + /// header parameters can be used to integrity check additional claims. + void setProtectedHeader(String key, dynamic value) { + _protectedHeaderParameters[key] = value; + } + + /// The media type [IANA.MediaTypes](https://www.iana.org/assignments/media-types/media-types.xhtml) + /// of the payload. + /// + /// This is the 'cty' header parameter + String? get mediaType => _protectedHeaderParameters['cty']; + + set mediaType(String? v) => _protectedHeaderParameters['cty'] = v; + + /// Returns the protected header parameters as a [JoseHeader] object + JoseHeader get protectedHeader => + JoseHeader.fromJson(_protectedHeaderParameters); + + /// Returns the payload and protected headers as a [JosePayload] object + JosePayload? get payload => + data == null ? null : JosePayload(data!, protectedHeader); + + /// Adds a [key] and [algorithm] to sign or encrypt this object + /// + /// [JsonWebSignature] and [JsonWebEncryption] can have multiple recipients + /// that use different keys. The compact serialization as a string can only + /// have one recipient however. + void addRecipient(JsonWebKey? key, {String? algorithm}) { + recipients.add({'_jwk': key, 'alg': algorithm}); + } + + /// Build the [JsonWebSignature] or [JsonWebEncryption] + T build(); +} + +class JoseException implements Exception { + final String message; + JoseException(this.message); + + @override + String toString() { + return 'JoseException: $message'; + } +} diff --git a/packages/jose_plus/lib/src/jwa.dart b/packages/jose_plus/lib/src/jwa.dart new file mode 100644 index 00000000..ece993eb --- /dev/null +++ b/packages/jose_plus/lib/src/jwa.dart @@ -0,0 +1,311 @@ +/// [JSON Web Algorithms](https://tools.ietf.org/html/rfc7518) +// ignore_for_file: constant_identifier_names + +library; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:jose_plus/src/util.dart'; +import 'package:meta/meta.dart'; + +class JsonWebAlgorithm { + /// Name of the algorithm as used in the `alg` or `enc` header parameter + /// values. + final String name; + + /// The cryptographic algorithm family, such as `RSA` or `EC`. + final String type; + + /// The intended use of the algorithm. + /// + /// Supported values are: + /// + /// * `sig` (signature) + /// * `enc` (encryption) + /// * `key` (key management) + final String use; + + /// The minimum bit length of the key + final int? minKeyBitLength; + + final String? curve; + + const JsonWebAlgorithm(this.name, + {required this.type, + required this.use, + this.minKeyBitLength, + this.curve}); + + static JsonWebAlgorithm getByName(String? name) { + return allAlgorithms.firstWhere((element) => element.name == name, + orElse: (() => + throw UnsupportedError('Algorithm \'$name\' not supported.'))); + } + + static Iterable find({ + String? operation, + String? keyType, + }) sync* { + for (var a in allAlgorithms) { + if (operation != null) { + if (!a.keyOperations.contains(operation)) continue; + } + if (keyType != null) { + if (a.type != keyType) continue; + } + yield a; + } + } + + static const List allAlgorithms = [ + hs256, + hs384, + hs512, + rs256, + rs384, + rs512, + es256, + es384, + es512, + es256k, + ps256, + ps384, + ps512, + eddsa, + rsa1_5, + rsa_oaep, + rsa_oaep_256, + a128kw, + a192kw, + a256kw, + ecdh_es, + ecdh_es_a128kw, + ecdh_es_a192kw, + ecdh_es_a256kw, + a128cbc_hs256, + a192cbc_hs384, + a256cbc_hs512, + a128gcm, + a192gcm, + a256gcm, + ]; + + /// HMAC using SHA-256 + static const hs256 = + JsonWebAlgorithm('HS256', type: 'oct', use: 'sig', minKeyBitLength: 256); + + /// HMAC using SHA-384 + static const hs384 = + JsonWebAlgorithm('HS384', type: 'oct', use: 'sig', minKeyBitLength: 384); + + /// HMAC using SHA-512 + static const hs512 = + JsonWebAlgorithm('HS512', type: 'oct', use: 'sig', minKeyBitLength: 512); + + /// RSASSA-PKCS1-v1_5 using SHA-256 + static const rs256 = + JsonWebAlgorithm('RS256', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// RSASSA-PKCS1-v1_5 using SHA-384 + static const rs384 = + JsonWebAlgorithm('RS384', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// RSASSA-PKCS1-v1_5 using SHA-512 + static const rs512 = + JsonWebAlgorithm('RS512', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// ECDSA using P-256 and SHA-256 + static const es256 = + JsonWebAlgorithm('ES256', type: 'EC', use: 'sig', curve: 'P-256'); + + /// ECDSA using P-384 and SHA-384 + static const es384 = + JsonWebAlgorithm('ES384', type: 'EC', use: 'sig', curve: 'P-384'); + + /// ECDSA using P-521 and SHA-512 + static const es512 = + JsonWebAlgorithm('ES512', type: 'EC', use: 'sig', curve: 'P-521'); + + /// ECDSA using P-256 and SHA-256 + static const es256k = + JsonWebAlgorithm('ES256K', type: 'EC', use: 'sig', curve: 'P-256K'); + + /// RSASSA-PSS using SHA-256 and MGF1 with SHA-256 + static const ps256 = + JsonWebAlgorithm('PS256', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// RSASSA-PSS using SHA-384 and MGF1 with SHA-384 + static const ps384 = + JsonWebAlgorithm('PS384', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// RSASSA-PSS using SHA-512 and MGF1 with SHA-512 + static const ps512 = + JsonWebAlgorithm('PS512', type: 'RSA', use: 'sig', minKeyBitLength: 2048); + + /// EdDSA signature algorithms (RFC 8037) using the Ed25519 curve. + static const eddsa = + JsonWebAlgorithm('EdDSA', type: 'OKP', use: 'sig', curve: 'Ed25519'); + + /// RSAES-PKCS1-v1_5 + static const rsa1_5 = JsonWebAlgorithm('RSA1_5', + type: 'RSA', use: 'key', minKeyBitLength: 2048); + + /// RSAES OAEP using default parameters + static const rsa_oaep = JsonWebAlgorithm('RSA-OAEP', + type: 'RSA', use: 'key', minKeyBitLength: 2048); + + /// RSAES OAEP using SHA-256 and MGF1 with SHA-256 + static const rsa_oaep_256 = JsonWebAlgorithm('RSA-OAEP-256', + type: 'RSA', use: 'key', minKeyBitLength: 2048); + + /// AES Key Wrap with default initial value using 128-bit key + static const a128kw = + JsonWebAlgorithm('A128KW', type: 'oct', use: 'key', minKeyBitLength: 128); + + /// AES Key Wrap with default initial value using 192-bit key + static const a192kw = + JsonWebAlgorithm('A192KW', type: 'oct', use: 'key', minKeyBitLength: 192); + + /// AES Key Wrap with default initial value using 256-bit key + static const a256kw = + JsonWebAlgorithm('A256KW', type: 'oct', use: 'key', minKeyBitLength: 256); + + /// ECDH-ES using Concat KDF + static const ecdh_es = + JsonWebAlgorithm('ECDH-ES', type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A128KW wrapping + static const ecdh_es_a128kw = JsonWebAlgorithm('ECDH-ES+A128KW', + type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A192KW wrapping + static const ecdh_es_a192kw = JsonWebAlgorithm('ECDH-ES+A192KW', + type: 'EC', use: 'key', curve: 'P-256'); + + /// ECDH-ES using Concat KDF and A256KW wrapping + static const ecdh_es_a256kw = JsonWebAlgorithm('ECDH-ES+A256KW', + type: 'EC', use: 'key', curve: 'P-256'); + + /// AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm + static const a128cbc_hs256 = JsonWebAlgorithm('A128CBC-HS256', + type: 'oct', use: 'enc', minKeyBitLength: 256); + + /// AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm + static const a192cbc_hs384 = JsonWebAlgorithm('A192CBC-HS384', + type: 'oct', use: 'enc', minKeyBitLength: 384); + + /// AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm + static const a256cbc_hs512 = JsonWebAlgorithm('A256CBC-HS512', + type: 'oct', use: 'enc', minKeyBitLength: 512); + + /// AES GCM using 128-bit key + static const a128gcm = JsonWebAlgorithm('A128GCM', + type: 'oct', use: 'enc', minKeyBitLength: 128); + + /// AES GCM using 192-bit key + static const a192gcm = JsonWebAlgorithm('A192GCM', + type: 'oct', use: 'enc', minKeyBitLength: 192); + + /// AES GCM using 256-bit key + static const a256gcm = JsonWebAlgorithm('A256GCM', + type: 'oct', use: 'enc', minKeyBitLength: 256); + + /// The operation(s) that the key is intended to be used for. + /// + /// Values defined by the specification are: + /// + /// * `sign` (compute digital signature or MAC) + /// * `verify` (verify digital signature or MAC) + /// * `encrypt` (encrypt content) + /// * `decrypt` (decrypt content and validate decryption, if applicable) + /// * `wrapKey` (encrypt key) + /// * `unwrapKey` (decrypt key and validate decryption, if applicable) + /// * `deriveKey` (derive key) + /// * `deriveBits` (derive bits not to be used as a key) + /// + /// Other values MAY be used. + List get keyOperations { + switch (use) { + case 'sig': + return ['sign', 'verify']; + case 'key': + return ['wrapKey', 'unwrapKey']; + case 'enc': + return ['encrypt', 'decrypt']; + } + throw UnsupportedError('Algorithms for use \'$use\' not supported'); + } + + @visibleForTesting + JsonWebKey? jwkFromCryptoKeyPair(KeyPair keyPair) { + return JsonWebKey.fromJson({ + 'kty': type, + if (type == 'oct') + 'k': encodeBase64EncodedBytes( + (keyPair.publicKey as SymmetricKey).keyValue), + if (type == 'RSA') ...{ + 'n': encodeBigInt((keyPair.publicKey as RsaPublicKey).modulus), + 'e': encodeBigInt((keyPair.publicKey as RsaPublicKey).exponent), + 'd': + encodeBigInt((keyPair.privateKey as RsaPrivateKey).privateExponent), + 'p': encodeBigInt( + (keyPair.privateKey as RsaPrivateKey).firstPrimeFactor), + 'q': encodeBigInt( + (keyPair.privateKey as RsaPrivateKey).secondPrimeFactor), + }, + if (type == 'EC') ...{ + 'd': encodeBigInt((keyPair.privateKey as EcPrivateKey).eccPrivateKey), + 'x': encodeBigInt((keyPair.publicKey as EcPublicKey).xCoordinate), + 'y': encodeBigInt((keyPair.publicKey as EcPublicKey).yCoordinate), + 'crv': curve, + }, + if (type == 'OKP') ...{ + // RFC 8037 §2: `x`/`d` are base64url of the RAW octet string, not of a + // big-endian integer like the EC coordinates above. + 'crv': curve, + 'x': encodeBase64EncodedBytes( + (keyPair.publicKey as OkpPublicKey).rawBytes), + if (keyPair.privateKey != null) + 'd': encodeBase64EncodedBytes( + (keyPair.privateKey as OkpPrivateKey).rawBytes), + }, + 'alg': name, + 'use': use, + 'key_ops': keyOperations + }); + } + + JsonWebKey generateRandomKey({int? keyBitLength}) { + final res = jwkFromCryptoKeyPair( + generateCryptoKeyPair(keyBitLength: keyBitLength), + ); + if (res == null) { + throw UnimplementedError('Unkown key type.'); + } + return res; + } + + @visibleForTesting + KeyPair generateCryptoKeyPair({int? keyBitLength}) { + switch (type) { + case 'oct': + return KeyPair.generateSymmetric(_getKeyBitLength(keyBitLength)); + case 'RSA': + return KeyPair.generateRsa(bitStrength: _getKeyBitLength(keyBitLength)); + case 'EC': + return KeyPair.generateEc(curvesByName[curve!]!); + case 'OKP': + return KeyPair.generateEd25519(); + } + throw UnsupportedError('Algorithms of type \'$type\' not supported'); + } + + int _getKeyBitLength(int? keyBitLength) { + keyBitLength ??= minKeyBitLength; + if (keyBitLength! < minKeyBitLength!) { + throw ArgumentError.value(keyBitLength, 'keyLength', + 'Minimum key length for algorithm $name is $minKeyBitLength'); + } + return keyBitLength; + } +} diff --git a/packages/jose_plus/lib/src/jwe.dart b/packages/jose_plus/lib/src/jwe.dart new file mode 100644 index 00000000..3279d09e --- /dev/null +++ b/packages/jose_plus/lib/src/jwe.dart @@ -0,0 +1,449 @@ +/// [JSON Web Encryption](https://tools.ietf.org/html/rfc7516) +library; + +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; + +import 'ecdh.dart'; +import 'jose.dart'; +import 'jwk.dart'; +import 'util.dart'; + +/// JSON Web Encryption (JWE) represents encrypted content using JSON-based data +/// structures +class JsonWebEncryption extends JoseObject { + /// Initialization Vector value used when encrypting the plaintext. + /// + /// Note that some algorithms may not use an Initialization Vector, in which + /// case this value is the empty octet sequence. + final List initializationVector; + + /// Additional value to be integrity protected by the authenticated encryption + /// operation. + /// + /// This can only be present when using the JWE JSON Serialization. + /// + /// Note that this can also be achieved when using either the JWE Compact + /// Serialization or the JWE JSON Serialization by including the AAD value as + /// an integrity-protected Header Parameter value, but at the cost of the + /// value being double base64url encoded. + final List? additionalAuthenticatedData; + + /// Authentication Tag value resulting from authenticated encryption of the + /// plaintext with Additional Authenticated Data. + final List authenticationTag; + + JsonWebEncryption._( + super.data, + List<_JweRecipient> super.recipients, { + JsonObject? protectedHeader, + JsonObject? unprotectedHeader, + this.initializationVector = const [], + this.additionalAuthenticatedData, + this.authenticationTag = const [], + }) : super( + sharedProtectedHeader: protectedHeader, + sharedUnprotectedHeader: unprotectedHeader); + + /// Constructs a [JsonWebEncryption] from its compact serialization + factory JsonWebEncryption.fromCompactSerialization(String serialization) { + var parts = serialization.split('.'); + if (parts.length != 5) { + throw ArgumentError.value( + serialization, 'Compact serialization should have 5 parts.'); + } + return JsonWebEncryption._( + decodeBase64EncodedBytes(parts[3]), + List.unmodifiable([ + _JweRecipient._(encryptedKey: decodeBase64EncodedBytes(parts[1])) + ]), + protectedHeader: JsonObject.decode(parts[0]), + initializationVector: decodeBase64EncodedBytes(parts[2]), + authenticationTag: decodeBase64EncodedBytes(parts[4])); + } + + /// Constructs a [JsonWebEncryption] from its flattened or general JSON + /// representation + JsonWebEncryption.fromJson(Map json) + : this._( + decodeBase64EncodedBytes(json['ciphertext']), + // Determine recipients according to RFC7516 general/flattened or + // fallback when neither recipients nor header are present. + List.unmodifiable(() { + if (json.containsKey('recipients')) { + // General JSON Serialization + return (json['recipients'] as List).map( + (v) => _JweRecipient._( + header: + v['header'] == null ? null : JsonObject.from(v['header']), + encryptedKey: decodeBase64EncodedBytes(v['encrypted_key']), + ), + ); + } + var encryptedKey = json.containsKey('encrypted_key') + ? decodeBase64EncodedBytes(json['encrypted_key']) + : []; + + if (json.containsKey('header')) { + // Flattened JSON Serialization (header present) + var hdr = json['header']; + return [ + _JweRecipient._( + header: hdr == null ? null : JsonObject.from(hdr), + encryptedKey: encryptedKey, + ), + ]; + } + // (Do not treat presence of only 'encrypted_key' as flattened; fall back to protected header derivation) + // Fallback: No recipients array and no per-recipient header. + // Try to derive necessary information from protected header. + // This supports cases where all header parameters (alg, enc, kid, ...) + // are only present in the protected header. + var protectedHeader = json['protected']; + if (protectedHeader == null) { + throw ArgumentError('Missing protected header for JWE'); + } + // We still build a single recipient to keep internal model consistent. + // encrypted_key may be legitimately absent for direct encryption (alg == 'dir'). + JsonObject? derivedRecipientHeader; + var phDecoded = JsonObject.decode(protectedHeader); + var alg = phDecoded['alg']; + var kid = phDecoded['kid']; + if (alg != null) { + // Populate derived recipient header so downstream logic finds per-recipient alg + derivedRecipientHeader = JsonObject.from({ + 'alg': alg, + if (kid != null) 'kid': kid, + }); + } + + // Encrypted key must be present unless alg == 'dir' or 'ECDH-ES'. + if (encryptedKey.isEmpty && + alg != null && + alg != 'dir' && + alg != 'ECDH-ES') { + throw ArgumentError('Missing encrypted_key for algorithm "$alg"'); + } + + return [ + _JweRecipient._( + header: derivedRecipientHeader, + encryptedKey: encryptedKey, + ), + ]; + }()), + protectedHeader: JsonObject.decode(json['protected']), + unprotectedHeader: json['unprotected'] == null + ? null + : JsonObject.from(json['unprotected']), + initializationVector: decodeBase64EncodedBytes(json['iv']), + additionalAuthenticatedData: json['aad'] == null + ? null + : decodeBase64EncodedBytes(json['aad']), + authenticationTag: decodeBase64EncodedBytes(json['tag']), + ); + + @override + String toCompactSerialization() { + if (recipients.length != 1) { + throw StateError( + 'Compact serialization does not support multiple recipients'); + } + if (sharedUnprotectedHeader != null) { + throw StateError( + 'Compact serialization does not support shared unprotected header'); + } + var recipient = recipients.first; + if (recipient.unprotectedHeader != null) { + throw StateError( + 'Compact serialization does not support unprotected header parameters'); + } + return '${sharedProtectedHeader!.toBase64EncodedString()}.' + '${encodeBase64EncodedBytes(recipient.data)}.' + '${encodeBase64EncodedBytes(initializationVector)}.' + '${encodeBase64EncodedBytes(data)}.' + '${encodeBase64EncodedBytes(authenticationTag)}'; + } + + @override + Map toJson() { + var aad = additionalAuthenticatedData; + var v = { + 'protected': sharedProtectedHeader?.toBase64EncodedString(), + 'unprotected': sharedUnprotectedHeader?.toJson(), + 'iv': encodeBase64EncodedBytes(initializationVector), + if (aad != null) 'aad': encodeBase64EncodedBytes(aad), + 'ciphertext': encodeBase64EncodedBytes(data), + 'tag': encodeBase64EncodedBytes(authenticationTag), + }; + if (recipients.length == 1) { + v.addAll(recipients.first.toJson()); + } else { + v['recipients'] = recipients.map((r) => r.toJson()).toList(); + } + return Map.fromEntries(v.entries.where((e) => e.value != null)); + } + + @override + List? getPayloadFor( + JsonWebKey? key, + JoseHeader header, + JoseRecipient recipient, + ) { + if (key == null) { + return null; + } + + var aad = sharedProtectedHeader?.toBase64EncodedString() ?? ''; + if (additionalAuthenticatedData != null) { + aad += '.${String.fromCharCodes(additionalAuthenticatedData!)}'; + } + if (header.encryptionAlgorithm == 'none') { + throw JoseException('Encryption algorithm cannot be `none`'); + } + + JsonWebKey cek; + if (header.algorithm == 'dir') { + cek = key; + } else if (header.algorithm != null && + header.algorithm!.startsWith('ECDH-ES')) { + // ECDH-ES key agreement + final epk = header.ephemeralPublicKey; + if (epk == null) { + throw JoseException('Missing ephemeral public key (epk) in header'); + } + final encAlgorithm = header.encryptionAlgorithm!; + final algId = ecdhAlgorithmId(header.algorithm!, encAlgorithm); + final keyLen = ecdhKeyDataLen(header.algorithm!, encAlgorithm); + + final apu = header.agreementPartyUInfo != null + ? Uint8List.fromList( + decodeBase64EncodedBytes(header.agreementPartyUInfo!)) + : null; + final apv = header.agreementPartyVInfo != null + ? Uint8List.fromList( + decodeBase64EncodedBytes(header.agreementPartyVInfo!)) + : null; + + final derivedKey = ecdhEsDecrypt( + recipientPrivateKey: key, + ephemeralPublicKey: epk, + algorithmId: algId, + keyDataLen: keyLen, + apu: apu, + apv: apv, + ); + + if (header.algorithm == 'ECDH-ES') { + // Direct key agreement — derived key IS the CEK + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(derivedKey), + 'use': 'enc', + 'key_ops': ['encrypt', 'decrypt'], + })!; + } else { + // ECDH-ES+A*KW — derived key unwraps the CEK + final unwrappedCekBytes = ecdhEsUnwrapKey(derivedKey, recipient.data); + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(unwrappedCekBytes), + 'use': 'enc', + 'key_ops': ['encrypt', 'decrypt'], + })!; + } + } else { + cek = key.unwrapKey(recipient.data, algorithm: header.algorithm); + } + + var uncompressed = cek.decrypt(data, + initializationVector: initializationVector, + additionalAuthenticatedData: Uint8List.fromList(aad.codeUnits), + authenticationTag: authenticationTag, + algorithm: header.encryptionAlgorithm); + switch (header.compressionAlgorithm) { + case null: + return uncompressed; + case 'DEF': + return Inflate(uncompressed).getBytes(); + default: + throw JoseException( + 'Unsupported compression algorithm ${header.compressionAlgorithm}'); + } + } +} + +class _JweRecipient extends JoseRecipient { + _JweRecipient._({JsonObject? header, required List encryptedKey}) + : super(unprotectedHeader: header, data: encryptedKey); + + @override + Map toJson() => { + 'header': unprotectedHeader?.toJson(), + 'encrypted_key': encodeBase64EncodedBytes(data) + }; +} + +/// Builder for [JsonWebSignature] +class JsonWebEncryptionBuilder extends JoseObjectBuilder { + /// Additional value to be integrity protected by the authenticated encryption + /// operation. + List? additionalAuthenticatedData; + + /// The content encryption algorithm to be used to perform authenticated + /// encryption on the plaintext to produce the ciphertext and the + /// Authentication Tag. + String? encryptionAlgorithm = 'A128CBC-HS256'; + + /// The compression algorithm to be used to compress the plaintext before + /// encryption. + String? compressionAlgorithm; + + @override + JsonWebEncryption build() { + if (encryptionAlgorithm == null) { + throw StateError('No encryption algorithm set'); + } + if (encryptionAlgorithm == 'none') { + throw StateError('Encryption algorithm cannot be `none`'); + } + if (recipients.isEmpty) { + throw StateError('Need at least one recipient'); + } + var payload = this.payload; + if (payload == null) { + throw StateError('No payload set'); + } + + var compact = recipients.length == 1 && additionalAuthenticatedData == null; + + var cek = JsonWebKey.generate(encryptionAlgorithm); + var sharedUnprotectedHeaderParams = { + 'enc': encryptionAlgorithm, + if (compressionAlgorithm != null) 'zip': compressionAlgorithm, + }; + + var recipientsMapped = recipients.map((r) { + var key = r['_jwk'] as JsonWebKey; + var algorithm = r['alg'] ?? key.algorithmForOperation('wrapKey') ?? 'dir'; + + List encryptedKey; + var unprotectedHeaderParams = {'alg': algorithm}; + + if (algorithm == 'dir') { + if (recipients.length > 1) { + throw StateError( + 'JWE can only have one recipient when using direct encryption with a shared symmetric key.'); + } + final k = JsonWebKey.fromJson({ + 'alg': encryptionAlgorithm, + ...key.toJson(), + }); + if (k == null) { + throw UnimplementedError('Unkown key.'); + } + cek = k; + encryptedKey = const []; + } else if (algorithm.startsWith('ECDH-ES')) { + // ECDH-ES key agreement + final algId = ecdhAlgorithmId(algorithm, encryptionAlgorithm!); + final keyLen = ecdhKeyDataLen(algorithm, encryptionAlgorithm!); + + // `apu`/`apv` are OPTIONAL, but when present in the header they are part + // of the Concat KDF OtherInfo (PartyUInfo/PartyVInfo, RFC 7518 4.6.2). + final header = payload.protectedHeader; + final apu = header?.getTyped('apu'); + final apv = header?.getTyped('apv'); + + final result = ecdhEsDerive( + recipientPublicKey: key, + algorithmId: algId, + keyDataLen: keyLen, + apu: apu == null + ? null + : Uint8List.fromList(decodeBase64EncodedBytes(apu)), + apv: apv == null + ? null + : Uint8List.fromList(decodeBase64EncodedBytes(apv)), + ); + + // Add EPK to header + unprotectedHeaderParams['epk'] = result.ephemeralPublicKey.toJson(); + + if (algorithm == 'ECDH-ES') { + // Direct key agreement — derived key IS the CEK + if (recipients.length > 1) { + throw StateError( + 'JWE can only have one recipient when using ECDH-ES direct key agreement.'); + } + cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(result.derivedKey), + 'use': 'enc', + 'alg': encryptionAlgorithm, + 'key_ops': ['encrypt', 'decrypt'], + })!; + encryptedKey = const []; + } else { + // ECDH-ES+A*KW — derived key wraps the CEK + encryptedKey = ecdhEsWrapKey(result.derivedKey, cek); + } + } else { + encryptedKey = key.wrapKey(cek, algorithm: algorithm); + } + if (key.keyId != null) { + unprotectedHeaderParams['kid'] = key.keyId; + } + if (compact) { + sharedUnprotectedHeaderParams.addAll(unprotectedHeaderParams); + } + + return _JweRecipient._( + encryptedKey: encryptedKey, + header: compact ? null : JsonObject.from(unprotectedHeaderParams)); + }).toList(); + + var protectedHeader = payload.protectedHeader; + if (compact) { + protectedHeader = JsonObject.from(safeUnion( + [protectedHeader?.toJson(), sharedUnprotectedHeaderParams])); + } + var aad = protectedHeader!.toBase64EncodedString(); + if (additionalAuthenticatedData != null) { + aad += '.${String.fromCharCodes(additionalAuthenticatedData!)}'; + } + + // RFC 7518 requires that a 96 bit iv is used with AESGCM and cjose insists + var iv = (encryptionAlgorithm != null && + encryptionAlgorithm!.contains(RegExp('A[0-9][0-9][0-9]GCM'))) + ? Uint8List.fromList( + List.generate(12, (_) => Random.secure().nextInt(256))) + : null; + + var data = this.data!; + + switch (compressionAlgorithm) { + case null: + break; + case 'DEF': + data = Deflate(data).getBytes(); + break; + default: + throw JoseException( + 'Unsupported compression algorithm $compressionAlgorithm'); + } + + var encryptedData = cek.encrypt(data, + initializationVector: iv, + additionalAuthenticatedData: Uint8List.fromList(aad.codeUnits)); + return JsonWebEncryption._(encryptedData.data, recipientsMapped, + protectedHeader: protectedHeader, + unprotectedHeader: + compact ? null : JsonObject.from(sharedUnprotectedHeaderParams), + initializationVector: encryptedData.initializationVector!, + authenticationTag: encryptedData.authenticationTag!, + additionalAuthenticatedData: additionalAuthenticatedData); + } +} diff --git a/packages/jose_plus/lib/src/jwk.dart b/packages/jose_plus/lib/src/jwk.dart new file mode 100644 index 00000000..c749b47b --- /dev/null +++ b/packages/jose_plus/lib/src/jwk.dart @@ -0,0 +1,633 @@ +/// [JSON Web Key](https://tools.ietf.org/html/rfc7517) +library; + +import 'package:clock/clock.dart'; +import 'dart:async'; +import 'dart:async' as async show runZoned; +import 'dart:convert'; +import 'dart:convert' as convert; +import 'dart:typed_data'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'jwa.dart'; +import 'package:meta/meta.dart'; +import 'package:x509_plus/x509.dart' as x509; + +import 'jose.dart'; +import 'util.dart'; + +/// JSON Web Key (JWK) represents a cryptographic key +class JsonWebKey extends JsonObject { + final KeyPair _keyPair; + + JsonWebKey.fromKeyPair({ + required KeyPair keyPair, + required Map json, + }) : _keyPair = keyPair, + super.from(json) { + if (json['kty'] == null) throw ArgumentError.notNull('keyType'); + if (x509CertificateChain != null && x509CertificateChain!.isNotEmpty) { + var cert = x509CertificateChain!.first; + + if (_keyPair.publicKey != cert.publicKey) { + throw ArgumentError("The public key in 'x5c' does not match this key."); + } + } + } + + /// Constructs a [JsonWebKey] from its JSON representation + static JsonWebKey? fromJson(Map json) { + final kp = KeyPair.fromJwk(json); + if (kp == null) { + return null; + } + return JsonWebKey.fromKeyPair(keyPair: kp, json: json); + } + + static String _bytesToBase64(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); + } + + static String _intToBase64(BigInt v) { + var s = v.toRadixString(16); + if (s.length % 2 != 0) s = '0$s'; + + return _bytesToBase64(s + .replaceAllMapped(RegExp('[0-9a-f]{2}'), (m) => '${m.group(0)},') + .split(',') + .where((v) => v.isNotEmpty) + .map((v) => int.parse(v, radix: 16)) + .toList()); + } + + /// Creates a JsonWebKey from a [PublicKey] and/or [PrivateKey] + factory JsonWebKey.fromCryptoKeys({ + PublicKey? publicKey, + PrivateKey? privateKey, + String? keyId, + }) { + if (publicKey == null && privateKey == null) { + throw ArgumentError('Either publicKey or privateKey should be non null'); + } + + if (privateKey is RsaPrivateKey) { + if (publicKey != null && publicKey is! RsaPublicKey) { + throw ArgumentError.value( + publicKey, 'publicKey', 'should be an RsaPublicKey'); + } + + return JsonWebKey.rsa( + modulus: privateKey.modulus, + exponent: (publicKey as RsaPublicKey?)?.exponent, + privateExponent: privateKey.privateExponent, + firstPrimeFactor: privateKey.firstPrimeFactor, + secondPrimeFactor: privateKey.secondPrimeFactor, + keyId: keyId, + ); + } + + String toCurveName(Identifier? curve) { + return curvesByName.entries + .firstWhere((element) => element.value == curve) + .key; + } + + if (privateKey is EcPrivateKey) { + if (publicKey != null && publicKey is! EcPublicKey) { + throw ArgumentError.value( + publicKey, 'publicKey', 'should be an EcPublicKey'); + } + + return JsonWebKey.ec( + curve: toCurveName(privateKey.curve), + privateKey: privateKey.eccPrivateKey, + xCoordinate: (publicKey as EcPublicKey?)?.xCoordinate, + yCoordinate: publicKey?.yCoordinate, + keyId: keyId, + ); + } + + if (privateKey != null) { + throw UnsupportedError( + 'Private key of type ${privateKey.runtimeType} not supported'); + } + + if (publicKey is RsaPublicKey) { + return JsonWebKey.rsa( + modulus: publicKey.modulus, + exponent: publicKey.exponent, + keyId: keyId, + ); + } + + if (publicKey is EcPublicKey) { + return JsonWebKey.ec( + curve: toCurveName(publicKey.curve), + xCoordinate: publicKey.xCoordinate, + yCoordinate: publicKey.yCoordinate, + keyId: keyId); + } + + throw UnsupportedError( + 'Public key of type ${publicKey.runtimeType} not supported'); + } + + /// Creates a JsonWebKey of type RSA + static JsonWebKey rsa({ + required BigInt modulus, + BigInt? exponent, + BigInt? privateExponent, + BigInt? firstPrimeFactor, + BigInt? secondPrimeFactor, + String? keyId, + String? algorithm, + }) => + JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': _intToBase64(modulus), + if (exponent != null) 'e': _intToBase64(exponent), + if (privateExponent != null) 'd': _intToBase64(privateExponent), + if (firstPrimeFactor != null) 'p': _intToBase64(firstPrimeFactor), + if (secondPrimeFactor != null) 'q': _intToBase64(secondPrimeFactor), + if (keyId != null) 'kid': keyId, + if (algorithm != null) 'alg': algorithm + })!; + + /// Creates a JsonWebKey of type EC + static JsonWebKey ec({ + required String curve, + BigInt? xCoordinate, + BigInt? yCoordinate, + BigInt? privateKey, + String? keyId, + String? algorithm, + }) => + JsonWebKey.fromJson({ + 'kty': 'EC', + 'crv': curve, + if (xCoordinate != null) 'x': _intToBase64(xCoordinate), + if (yCoordinate != null) 'y': _intToBase64(yCoordinate), + if (privateKey != null) 'd': _intToBase64(privateKey), + if (keyId != null) 'kid': keyId, + if (algorithm != null) 'alg': algorithm + })!; + + /// Creates a JsonWebKey of type oct + static JsonWebKey symmetric({required BigInt key, String? keyId}) => + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': _intToBase64(key), + if (keyId != null) 'kid': keyId, + })!; + + /// Parses a PEM encoded public or private key + factory JsonWebKey.fromPem(String pem, {String? keyId}) { + var v = x509.parsePem(pem).first; + + if (v is x509.PrivateKeyInfo) { + v = v.keyPair; + } + + if (v is KeyPair) { + return JsonWebKey.fromCryptoKeys( + publicKey: v.publicKey, privateKey: v.privateKey, keyId: keyId); + } + if (v is x509.X509Certificate) { + v = v.tbsCertificate.subjectPublicKeyInfo; + } + if (v is x509.SubjectPublicKeyInfo) { + return JsonWebKey.fromCryptoKeys( + publicKey: v.subjectPublicKey, + keyId: keyId, + ); + } + throw UnsupportedError('Cannot create JWK from ${v.runtimeType}'); + } + + /// Generates a random key suitable for the specified [algorithm] + factory JsonWebKey.generate(String? algorithm, {int? keyBitLength}) { + var alg = JsonWebAlgorithm.getByName(algorithm); + return alg.generateRandomKey(keyBitLength: keyBitLength); + } + + KeyPair get cryptoKeyPair => _keyPair; + + /// The cryptographic algorithm family used with the key, such as `RSA` or + /// `EC`. + String get keyType => this['kty']; + + /// The intended use of the public key. + /// + /// Values defined by the specification are: + /// + /// * `sig` (signature) + /// * `enc` (encryption) + /// + /// Other values MAY be used. + /// + String? get publicKeyUse => this['use']; + + /// The operation(s) that the key is intended to be used for. + /// + /// Values defined by the specification are: + /// + /// * `sign` (compute digital signature or MAC) + /// * `verify` (verify digital signature or MAC) + /// * `encrypt` (encrypt content) + /// * `decrypt` (decrypt content and validate decryption, if applicable) + /// * `wrapKey` (encrypt key) + /// * `unwrapKey` (decrypt key and validate decryption, if applicable) + /// * `deriveKey` (derive key) + /// * `deriveBits` (derive bits not to be used as a key) + /// + /// Other values MAY be used. + Set? get keyOperations => getTypedList('key_ops')?.toSet(); + + /// The algorithm intended for use with the key. + String? get algorithm => this['alg']; + + /// Key ID used to match a specific key. + /// + /// This is used, for instance, to choose among a set of keys within a JWK Set + /// during key rollover. + String? get keyId => this['kid']; + + /// A resource for an X.509 public key certificate or certificate chain. + Uri? get x509Url => this['x5u'] == null ? null : Uri.parse(this['x5u']); + + /// A chain of one or more PKIX certificates. + List? get x509CertificateChain => + (this['x5c'] as List?)?.map((v) { + var bytes = convert.base64.decode(v); + var p = ASN1Parser(bytes); + var o = p.nextObject(); + if (o is! ASN1Sequence) { + throw FormatException('Expected SEQUENCE, got ${o.runtimeType}'); + } + var s = o; + return x509.X509Certificate.fromAsn1(s); + }).toList(); + + /// A base64url encoded SHA-1 thumbprint (a.k.a. digest) of the DER encoding + /// of an X.509 certificate. + String? get x509CertificateThumbprint => this['x5t']; + + /// A base64url encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding + /// of an X.509 certificate. + String? get x509CertificateSha256Thumbprint => this['x5t#S256']; + + /// Compute digital signature or MAC + List sign(List data, {String? algorithm}) { + _assertCanDo('sign'); + var signer = _keyPair.privateKey!.createSigner(_getAlgorithm(algorithm)); + var signature = signer.sign(data); + return signature.data; + } + + /// Verify digital signature or MAC + bool verify(List data, List signature, {String? algorithm}) { + _assertCanDo('verify'); + var verifier = _keyPair.publicKey!.createVerifier(_getAlgorithm(algorithm)); + return verifier.verify( + Uint8List.fromList(data), Signature(Uint8List.fromList(signature))); + } + + /// Encrypt content + EncryptionResult encrypt(List data, + {List? initializationVector, + List? additionalAuthenticatedData, + String? algorithm}) { + _assertCanDo('encrypt'); + algorithm ??= this.algorithm; + var encrypter = + _keyPair.publicKey!.createEncrypter(_getAlgorithm(algorithm)); + return encrypter.encrypt(Uint8List.fromList(data), + initializationVector: initializationVector != null + ? Uint8List.fromList(initializationVector) + : null, + additionalAuthenticatedData: additionalAuthenticatedData != null + ? Uint8List.fromList(additionalAuthenticatedData) + : null); + } + + /// Decrypt content and validate decryption, if applicable + List decrypt(List data, + {List? initializationVector, + List? authenticationTag, + List? additionalAuthenticatedData, + String? algorithm}) { + _assertCanDo('decrypt'); + algorithm ??= this.algorithm; + var decrypter = + _keyPair.privateKey!.createEncrypter(_getAlgorithm(algorithm)); + return decrypter.decrypt(EncryptionResult(Uint8List.fromList(data), + initializationVector: initializationVector != null + ? Uint8List.fromList(initializationVector) + : null, + authenticationTag: authenticationTag != null + ? Uint8List.fromList(authenticationTag) + : null, + additionalAuthenticatedData: additionalAuthenticatedData != null + ? Uint8List.fromList(additionalAuthenticatedData) + : null)); + } + + /// Encrypt key + List wrapKey(JsonWebKey key, {String? algorithm}) { + _assertCanDo('wrapKey'); + if (key.keyType != 'oct') { + throw UnsupportedError('Can only wrap symmetric keys'); + } + algorithm ??= this.algorithm; + var encrypter = + _keyPair.publicKey!.createEncrypter(_getAlgorithm(algorithm)); + var v = encrypter + .encrypt(Uint8List.fromList(decodeBase64EncodedBytes(key['k']))); + return v.data; + } + + /// Decrypt key and validate decryption, if applicable + JsonWebKey unwrapKey(List data, {String? algorithm}) { + _assertCanDo('unwrapKey'); + algorithm ??= this.algorithm; + var decrypter = + _keyPair.privateKey!.createEncrypter(_getAlgorithm(algorithm)); + var v = decrypter.decrypt(EncryptionResult(Uint8List.fromList(data))); + return JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': encodeBase64EncodedBytes(v), + 'use': 'enc', + 'key_ops': ['encrypt', 'decrypt'] + })!; + } + + /// Returns true if this key can be used with the JSON Web Algorithm + /// identified by [algorithm] + bool usableForAlgorithm(String algorithm) { + if (this.algorithm != null && this.algorithm != algorithm) return false; + var alg = JsonWebAlgorithm.getByName(algorithm); + + return alg.type == keyType; + } + + /// Returns true if this key can be used for the [operation] + /// + /// The value of [operation] should be one of the possible values for + /// [keyOperations]. + bool usableForOperation(String operation) { + var ops = keyOperations; + if (ops != null && !ops.contains(operation)) return false; + var alg = algorithm == null ? null : JsonWebAlgorithm.getByName(algorithm); + if (alg != null && publicKeyUse != null && alg.use != publicKeyUse) { + return false; + } + + switch (operation) { + case 'sign': + case 'unwrapKey': + case 'decrypt': + return _keyPair.privateKey != null; + case 'verify': + case 'wrapKey': + case 'encrypt': + return _keyPair.publicKey != null; + } + return false; + } + + /// Returns a JSON Web Algorithm name that can be used with this key for + /// [operation] + String? algorithmForOperation(String operation) { + if (!usableForOperation(operation)) return null; + if (algorithm != null) return algorithm; + + return JsonWebAlgorithm.find(operation: operation, keyType: keyType) + .firstOrNull + ?.name; + } + + AlgorithmIdentifier _getAlgorithm(String? algorithm) { + algorithm ??= this['alg']; + if (this['alg'] != null) { + if (this['alg'] != algorithm) { + throw ArgumentError.value(algorithm, 'algorithm', + "Algorithm should match key algorithm '${this['alg']}'"); + } + } + if (algorithm == null) { + throw ArgumentError('No algorithm specified'); + } + var id = AlgorithmIdentifier.getByJwaName(algorithm); + + if (id == null) { + throw UnsupportedError('Algorithm with name $algorithm not found'); + } + return id; + } + + void _assertCanDo(String op) { + if (!usableForOperation(op)) { + throw StateError("This JsonWebKey does not support the '$op' operation."); + } + } +} + +/// Represents a set of [JsonWebKey]s +class JsonWebKeySet extends JsonObject { + /// An array of JWK values + List get keys => + getTypedList('keys', factory: (v) => JsonWebKey.fromJson(v)) + ?.nonNulls + .toList() ?? + const []; + + /// Constructs a [JsonWebKeySet] from the list of [keys] + factory JsonWebKeySet.fromKeys(Iterable keys) => + JsonWebKeySet.fromJson({ + 'keys': keys.map((v) => v?.toJson()).nonNulls.toList(), + }); + + /// Constructs a [JsonWebKeySet] from its JSON representation + JsonWebKeySet.fromJson(Map super.json) : super.from(); +} + +/// A key store to lookup [JsonWebKey]s +class JsonWebKeyStore { + final List _keys = []; + final List _keySets = []; + final List _keySetUrls = []; + + /// Adds a key set to this tore + void addKeySet(JsonWebKeySet keys) => _keySets.add(keys); + + /// Adds a key to this store + void addKey(JsonWebKey? key) { + if (key == null) { + return; + } + _keys.add(key); + } + + /// Adds a key set url to this store + void addKeySetUrl(Uri url) => _keySetUrls.add(url); + + /// Find [JsonWebKey]s for a [JoseObject] with header [header]. + /// + /// See also [https://tools.ietf.org/html/rfc7515#appendix-D] + Stream findJsonWebKeys(JoseHeader header, String operation) { + if (header.algorithm == 'none') return Stream.fromIterable([null]); + return _allKeys(header).where( + (key) => _isValidKeyFor(key, header, operation), + ); + } + + Stream _allKeys(JoseHeader header) async* { + // The keys added with `addKey` + yield* Stream.fromIterable(_keys); + + // The keys added with `addKeySet` + for (var s in _keySets) { + yield* Stream.fromIterable(s.keys); + } + + // The keys added with `addKeySetUrl` + for (var url in _keySetUrls) { + yield* _keysFromSet(url).where((v) => v != null).cast(); + } + } + + bool _isValidKeyFor(JsonWebKey? key, JoseHeader header, String operation) { + if (key == null) { + return false; + } + + if ((header.keyId != null) && (header.keyId != key.keyId)) { + return false; + } + + return key.usableForAlgorithm( + operation == 'encrypt' || operation == 'decrypt' + ? header.encryptionAlgorithm! + : header.algorithm!) && + key.usableForOperation(operation); + } + + Stream _keysFromSet(Uri uri) async* { + var set = await JsonWebKeySetLoader.current.read(uri); + yield* Stream.fromIterable(set.keys); + } +} + +/// Used for loading JSON Web Key sets from an url +/// +/// The default loader will handle urls with `data` and `http(s)` schemes. Http +/// requests will be cached. +/// +/// The default behavior can be changed with [runZoned]. This can be useful for +/// testing purposes or changing the caching behavior. +/// +/// Example: +/// +/// JsonWebKeySetLoader.runZoned(() async { +/// var key = await set.findJsonWebKeys(header, operation).first; +/// }, loader: DefaultJsonWebKeySetLoader(httpClient: MockClient())); +/// +abstract class JsonWebKeySetLoader { + @visibleForOverriding + Future readAsString(Uri uri); + + Future read(Uri uri) async { + return JsonWebKeySet.fromJson(convert.json.decode(await readAsString(uri))); + } + + static JsonWebKeySetLoader _global = DefaultJsonWebKeySetLoader(); + + static JsonWebKeySetLoader get current { + return Zone.current[_jsonWebKeySetLoaderToken] ?? _global; + } + + static set global(JsonWebKeySetLoader value) { + _global = value; + } + + static final _jsonWebKeySetLoaderToken = Object(); + + static T runZoned(T Function() body, {JsonWebKeySetLoader? loader}) { + return async + .runZoned(body, zoneValues: {_jsonWebKeySetLoaderToken: loader}); + } +} + +/// A [JsonWebKeySetLoader] that uses [http.Client] to make http requests. +class DefaultJsonWebKeySetLoader extends JsonWebKeySetLoader { + final http.Client _httpClient; + + final Map> _cache = {}; + final Duration _cacheExpiry; + + /// Creates a [DefaultJsonWebKeySetLoader] + /// + /// A custom [httpClient] can be used for doing the http requests. + /// + /// If the response includes valid "date" and "expires" headers, those are + /// used instead of [cacheExpiry]. + DefaultJsonWebKeySetLoader({ + http.Client? httpClient, + Duration cacheExpiry = const Duration(minutes: 5), + }) : _httpClient = httpClient ?? http.Client(), + _cacheExpiry = cacheExpiry; + + @override + Future readAsString(Uri uri) async { + switch (uri.scheme) { + case 'data': + return uri.data!.contentAsString(); + case 'https': + case 'http': + var v = _cache[uri]; + if (v != null && v.key.isAfter(clock.now())) { + return v.value; + } + var r = await _httpClient.get(uri); + _cache[uri] = MapEntry(_localExpire(r), r.body); + return r.body; + default: + throw UnsupportedError( + 'Uri\'s with scheme ${uri.scheme} not supported', + ); + } + } + + DateTime _localExpire(http.Response response) { + DateTime? fromHeader(String key) { + final str = response.headers[key]; + if (str is String) { + try { + return parseHttpDate(str); + } on FormatException { + // noop! + } + } + return null; + } + + var delta = _cacheExpiry; + + final date = fromHeader('date'); + if (date != null) { + final expires = fromHeader('expires'); + + if (expires != null) { + // Since the server time may not match the local time, calculate a delta + final newDelta = expires.difference(date); + if (!newDelta.isNegative) { + delta = newDelta; + } + } + } + return clock.now().add(delta); + } +} diff --git a/packages/jose_plus/lib/src/jws.dart b/packages/jose_plus/lib/src/jws.dart new file mode 100644 index 00000000..f16e6efe --- /dev/null +++ b/packages/jose_plus/lib/src/jws.dart @@ -0,0 +1,195 @@ +/// [JSON Web Signature](https://tools.ietf.org/html/rfc7515) +library; + +import 'util.dart'; +import 'jwk.dart'; +import 'jose.dart'; +import 'dart:convert' as convert; + +/// JSON Web Signature (JWS) represents content secured with digital signatures +/// or Message Authentication Codes (MACs) using JSON-based data structures. +class JsonWebSignature extends JoseObject { + JsonWebSignature._(super.data, List<_JwsRecipient> super.recipients); + + /// Constructs a [JsonWebSignature] from its compact serialization + factory JsonWebSignature.fromCompactSerialization(String serialization) { + var parts = serialization.split('.'); + if (parts.length != 3) { + throw ArgumentError.value( + serialization, 'Compact serialization should have 3 parts.'); + } + return JsonWebSignature._( + decodeBase64EncodedBytes(parts[1]), + List.unmodifiable([ + _JwsRecipient( + protectedHeader: JsonObject.decode(parts[0]), + data: decodeBase64EncodedBytes(parts[2])) + ])); + } + + /// Constructs a [JsonWebSignature] from its flattened or general JSON + /// representation + factory JsonWebSignature.fromJson(Map json) { + Iterable<_JwsRecipient> signatures; + if (json.containsKey('signatures')) { + signatures = + (json['signatures'] as List).map((v) => _JwsRecipient.fromJson(v)); + } else { + signatures = [_JwsRecipient.fromJson(json)]; + } + return JsonWebSignature._( + decodeBase64EncodedBytes(json['payload']), + List.unmodifiable(signatures), + ); + } + + @override + Map toJson() { + var v = {'payload': encodeBase64EncodedBytes(data)}; + if (recipients.length == 1) { + v.addAll(recipients.first.toJson()); + } else { + v['signatures'] = recipients.map((v) => v.toJson()).toList(); + } + return v; + } + + @override + String toCompactSerialization() { + if (recipients.length != 1) { + throw StateError( + 'Compact serialization does not support multiple signatures'); + } + var signature = recipients.first; + if (signature.unprotectedHeader != null) { + throw StateError( + 'Compact serialization does not support unprotected header parameters'); + } + return '${signature.protectedHeader!.toBase64EncodedString()}.${encodeBase64EncodedBytes(data)}.' + '${encodeBase64EncodedBytes(signature.data)}'; + } + + /// Returns the unverified payload (with the protected header parameters from + /// the first signature) + JosePayload get unverifiedPayload => JosePayload(data, commonHeader); + + @override + List? getPayloadFor( + JsonWebKey? key, + JoseHeader header, + JoseRecipient recipient, + ) { + if (header.algorithm == 'none') { + return key == null && recipient.data.isEmpty ? this.data : null; + } + + if (key == null) { + return null; + } + + // verify header + var encodedHeader = recipient.protectedHeader!.toBase64EncodedString(); + var encodedPayload = encodeBase64EncodedBytes(this.data); + var data = convert.utf8.encode('$encodedHeader.$encodedPayload'); + + return key.verify(data, recipient.data, algorithm: header.algorithm) + ? this.data + : null; + } +} + +class _JwsRecipient extends JoseRecipient { + _JwsRecipient( + {super.protectedHeader, super.unprotectedHeader, required super.data}); + + _JwsRecipient.fromJson(Map json) + : this( + protectedHeader: json['protected'] == null + ? null + : JsonObject.decode(json['protected']), + unprotectedHeader: + json['header'] == null ? null : JsonObject.from(json['header']), + data: json['signature'] == null + ? const [] + : decodeBase64EncodedBytes(json['signature'])); + + factory _JwsRecipient._sign( + List payload, JsonObject protectedHeader, JsonWebKey? key, + {String? algorithm, bool protectAll = false}) { + // Compute the encoded payload value BASE64URL(JWS Payload) + var encodedPayload = encodeBase64EncodedBytes(payload); + + // Assemble the unprotected header + algorithm ??= key?.algorithmForOperation('sign') ?? 'none'; + var unprotectedHeaderParams = {'alg': algorithm}; + if (key?.keyId != null) { + unprotectedHeaderParams['kid'] = key!.keyId; + } + var commonKeys = protectedHeader + .toJson() + .keys + .toSet() + .intersection(unprotectedHeaderParams.keys.toSet()); + for (var k in commonKeys) { + if (unprotectedHeaderParams[k] != protectedHeader[k]) { + throw ArgumentError( + "Protected and unprotected have non-equal parameter '$k'"); + } + unprotectedHeaderParams.remove(k); + } + + // Compute the encoded header value BASE64URL(UTF8(JWS Protected Header)) + if (protectAll) { + protectedHeader = JsonObject.from( + unprotectedHeaderParams..addAll(protectedHeader.toJson())); + } + var unprotectedHeader = + protectAll ? null : JsonObject.from(unprotectedHeaderParams); + + var encodedHeader = protectedHeader.toBase64EncodedString(); + + var data = convert.utf8.encode('$encodedHeader.$encodedPayload'); + + var signature = algorithm == 'none' + ? const [] + : key!.sign(data, algorithm: algorithm); + + return _JwsRecipient( + protectedHeader: protectedHeader, + unprotectedHeader: unprotectedHeader, + data: signature); + } + + @override + Map toJson() { + return { + if (protectedHeader != null) + 'protected': protectedHeader!.toBase64EncodedString(), + if (unprotectedHeader != null) 'header': unprotectedHeader!.toJson(), + 'signature': encodeBase64EncodedBytes(data) + }; + } +} + +/// Builder for [JsonWebSignature] +class JsonWebSignatureBuilder extends JoseObjectBuilder { + @override + JsonWebSignature build() { + if (recipients.isEmpty) { + throw StateError('Need at least one recipient'); + } + var payload = this.payload; + if (payload == null) { + throw StateError('No payload set'); + } + + var signatures = recipients.map((r) { + var key = r['_jwk']; + var algorithm = r['alg']; + return _JwsRecipient._sign(payload.data, payload.protectedHeader!, key, + algorithm: algorithm, protectAll: recipients.length == 1); + }).toList(); + + return JsonWebSignature._(payload.data, signatures); + } +} diff --git a/packages/jose_plus/lib/src/jwt.dart b/packages/jose_plus/lib/src/jwt.dart new file mode 100644 index 00000000..261d2536 --- /dev/null +++ b/packages/jose_plus/lib/src/jwt.dart @@ -0,0 +1,154 @@ +/// [JSON Web Token](https://tools.ietf.org/html/rfc7519) +library; + +import 'package:clock/clock.dart'; + +import 'jose.dart'; +import 'jwk.dart'; +import 'util.dart'; +import 'dart:async'; +import 'jws.dart'; + +/// The set of claims conveyed by the [JsonWebToken] +class JsonWebTokenClaims extends JsonObject { + /// Constructs a [JsonWebTokenClaims] from a [json] map + JsonWebTokenClaims.fromJson(super.json) : super.from(); + + /// Identifies the principal that issued the JWT. + Uri? get issuer => getTyped('iss'); + + /// Session ID + String? get sid => getTyped('sid'); + + /// Identifies the principal that is the subject of the JWT. + /// + /// The claims in a JWT are normally statements about the subject. + /// + /// The subject value MUST either be scoped to be locally unique in the + /// context of the issuer or be globally unique. + String? get subject => this['sub']; + + /// Identifies the recipients that the JWT is intended for. + /// + /// Each principal intended to process the JWT MUST identify itself with a + /// value in the audience claim. If the principal processing the claim does + /// not identify itself with a value in the 'aud' claim when this claim is + /// present, then the JWT MUST be rejected. + List? get audience => getTypedList('aud'); + + /// Identifies the expiration time on or after which the JWT MUST NOT be + /// accepted for processing. + /// + /// Implementers MAY provide for some small leeway, usually no more than a few + /// minutes, to account for clock skew. + DateTime? get expiry => getTyped('exp'); + + /// Identifies the time before which the JWT MUST NOT be accepted for + /// processing. + /// + /// Implementers MAY provide for some small leeway, usually no more than a few + /// minutes, to account for clock skew. + DateTime? get notBefore => getTyped('nbf'); + + /// Identifies the time at which the JWT was issued. + /// + /// This claim can be used to determine the age of the JWT. + DateTime? get issuedAt => getTyped('iat'); + + /// Provides a unique identifier for the JWT. + /// + /// The identifier value MUST be assigned in a manner that ensures that there + /// is a negligible probability that the same value will be accidentally + /// assigned to a different data object; if the application uses multiple + /// issuers, collisions MUST be prevented among values produced by different + /// issuers as well. + /// + /// The 'jti' claim can be used to prevent the JWT from being replayed. + String? get jwtId => this['jti']; + + Iterable validate({ + Duration expiryTolerance = const Duration(), + Uri? issuer, + String? clientId, + }) sync* { + final now = clock.now(); + final diff = now.difference(expiry!); + if (diff > expiryTolerance) { + yield JoseException( + 'JWT expired. Expiry ($expiry) is more than tolerance ' + '($expiryTolerance) before now ($now)', + ); + } + if (issuer != null && this.issuer != issuer) { + yield JoseException( + 'Issuer does not match. Expected ' + '`$issuer`, was `${this.issuer}`', + ); + } + final aud = audience; + if (clientId != null && (aud == null || !aud.contains(clientId))) { + yield JoseException('Audiences does not contain clientId `$clientId`.'); + } + } +} + +/// Represents a JWT +class JsonWebToken { + final JoseObject _joseObject; + + /// The claims conveyed by the [JsonWebToken] + final JsonWebTokenClaims claims; + + bool? _verified; + + JsonWebToken._(this._joseObject, this.claims, this._verified); + + JsonWebToken._fromJws(JsonWebSignature jws) + : this._( + jws, + JsonWebTokenClaims.fromJson(jws.unverifiedPayload.jsonContent), + null); + + /// Decodes a JWT string from a JWS compact serialization, without verifying + /// the integrity + JsonWebToken.unverified(String serialization) + : this._fromJws(JsonWebSignature.fromCompactSerialization(serialization)); + + /// Decodes and verifies/decrypts a JWT string from a JWE or JWS compact + /// serialization + static Future decodeAndVerify( + String serialization, JsonWebKeyStore keyStore, + {List? allowedArguments}) async { + var joseObject = JoseObject.fromCompactSerialization(serialization); + var content = await joseObject.getPayload(keyStore, + allowedAlgorithms: allowedArguments); + JsonWebTokenClaims claims; + if (content.mediaType == 'JWT') { + claims = (await decodeAndVerify(content.stringContent, keyStore, + allowedArguments: allowedArguments)) + .claims; + } else { + claims = JsonWebTokenClaims.fromJson(content.jsonContent); + } + return JsonWebToken._(joseObject, claims, true); + } + + /// Serializes the [JsonWebToken] to a string + String toCompactSerialization() => _joseObject.toCompactSerialization(); + + /// Returns `true` if this JWT has been successfully verified, `false` if + /// verification failed and `null` if no verification attempt has been made + bool? get isVerified => _verified; + + /// Attempts to verify this JWT + Future verify(JsonWebKeyStore keyStore, + {List? allowedArguments}) async { + try { + await _joseObject.getPayload(keyStore, + allowedAlgorithms: allowedArguments); + return _verified = true; + } catch (e) { + return _verified = false; + } + } +} diff --git a/packages/jose_plus/lib/src/util.dart b/packages/jose_plus/lib/src/util.dart new file mode 100644 index 00000000..b3aac0d4 --- /dev/null +++ b/packages/jose_plus/lib/src/util.dart @@ -0,0 +1,158 @@ +import 'dart:convert' as convert; + +import 'package:crypto_keys_plus/crypto_keys.dart'; + +/// Holds an unmodifiable JSON object +/// +/// This object implements the equality operator, base64 (de)serialization and +/// convenient methods to get typed properties. +class JsonObject { + final Map _json; + String? _encodedString; + + JsonObject._(this._json, [this._encodedString]); + + /// Constructs a [JsonObject] from a [json] map. + /// + /// The [json] map is deep cloned to an unmodifiable copy. + JsonObject.from(Map? json) : this._(_clone(json)); + + /// Constructs a [JsonObject] from a [bytes] representation of the json string + JsonObject.fromBytes(List bytes) + : this._(_clone(convert.json.decode(convert.utf8.decode(bytes))), + encodeBase64EncodedBytes(bytes)); + + /// Constructs a [JsonObject] from a base64 [encodedString] representation of + /// the json string + JsonObject.decode(String encodedString) + : this.fromBytes(decodeBase64EncodedBytes(encodedString)); + + /// Returns a JSON representation + Map toJson() => _json; + + @override + int get hashCode => toBase64EncodedString().hashCode; + + @override + bool operator ==(other) => + other is JsonObject && + toBase64EncodedString() == other.toBase64EncodedString(); + + static dynamic _clone(dynamic v) { + if (v is Map) { + return Map.unmodifiable( + Map.fromIterables( + v.keys as Iterable, v.values.map(_clone))); + } + if (v is List) return List.unmodifiable(v.map(_clone)); + if (v == null || v is num || v is bool || v is String) return v; + throw ArgumentError.value(v, 'Not a json value'); + } + + /// Returns the bytes representing the encoded JSON + List toBytes() => decodeBase64EncodedBytes(toBase64EncodedString()); + + /// Returns the base64 representation + String toBase64EncodedString() => _encodedString ??= + encodeBase64EncodedBytes(convert.utf8.encode(convert.json.encode(_json))); + + /// Returns the property [key] as a core dart value + dynamic operator [](String key) => _json[key]; + + /// Returns the property [key] as a typed object + T? getTyped(String key, {T Function(dynamic v)? factory}) { + return _typedMap.putIfAbsent( + key, () => _convert(this[key], factory: factory)); + } + + /// Returns the property [key] as a typed list + List? getTypedList(String key, {T Function(dynamic v)? factory}) { + return _typedMap.putIfAbsent(key, () { + var v = this[key]; + if (v == null) return null; + + if (v is List) { + return List.unmodifiable( + v.map((i) => _convert(i, factory: factory))); + } + + return List.unmodifiable([_convert(v, factory: factory)]); + }); + } + + final Map _typedMap = {}; + + T? _convert(dynamic v, {T Function(dynamic v)? factory}) { + if (v == null) return null; + switch (T) { + case == Uri: + return Uri.parse(v) as T; + case == DateTime: + return DateTime.fromMillisecondsSinceEpoch(((v as num).round() * 1000)) + as T; + case == Duration: + return Duration(seconds: (v as num).round()) as T; + case == String: + case == num: + case == bool: + return v; + default: + return factory == null ? v : factory(v); + } + } + + @override + String toString() => _json.toString(); +} + +List decodeBase64EncodedBytes(String encodedString) => + convert.base64Url.decode(encodedString + + List.filled((4 - encodedString.length % 4) % 4, '=').join()); + +String encodeBase64EncodedBytes(List data) => + convert.base64Url.encode(data).replaceAll('=', ''); + +String encodeBigInt(BigInt? v) { + final b256 = BigInt.from(256); + var bytes = []; + while (v != BigInt.zero) { + bytes.add((v! % b256).toInt()); + v = v ~/ b256; + } + return convert.base64Url.encode(bytes.reversed.toList()); +} + +Map safeUnion(Iterable?> items) { + var out = {}; + for (var i in items) { + if (i == null) continue; + for (var k in out.keys.toSet().intersection(i.keys.toSet())) { + if (out[k] != i[k]) { + throw ArgumentError('Dublicate key `$k`'); + } + } + out.addAll(i); + } + return out; +} + +Map commonUnion(Iterable> items) { + if (items.isEmpty) return {}; + var out = {}..addAll(items.first); + for (var i in items) { + for (var k in out.keys.toList()) { + if (out[k] != i[k]) { + out.remove(k); + } + } + if (out.isEmpty) return out; + } + return out; +} + +final curvesByName = { + 'P-256': curves.p256, + 'P-256K': curves.p256k, + 'P-384': curves.p384, + 'P-521': curves.p521 +}; diff --git a/packages/jose_plus/pubspec.yaml b/packages/jose_plus/pubspec.yaml new file mode 100644 index 00000000..01e5fb72 --- /dev/null +++ b/packages/jose_plus/pubspec.yaml @@ -0,0 +1,29 @@ +name: jose_plus +description: Javascript Object Signing and Encryption (JOSE) library supporting JWE, JWS, JWK and JWT +version: 1.0.1 +repository: https://github.com/Bdaya-Dev/oidc/tree/main/packages/jose_plus +homepage: https://bdaya-dev.github.io/oidc/ +funding: + - https://github.com/sponsors/rbellens +resolution: workspace + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + crypto_keys_plus: ^1.0.0 + meta: ^1.1.6 + x509_plus: ^1.0.0 + http: ">=0.13.0 <2.0.0" + http_parser: ^4.0.0 + asn1lib: ^1.0.0 + clock: ^1.1.1 + archive: '>=3.6.1 <5.0.0' + +dev_dependencies: + test: ^1.24.4 + lints: ">=4.0.0 <7.0.0" + +false_secrets: + - test/pem/* + - example/*.key diff --git a/packages/jose_plus/test/ecdh_error_test.dart b/packages/jose_plus/test/ecdh_error_test.dart new file mode 100644 index 00000000..601724ce --- /dev/null +++ b/packages/jose_plus/test/ecdh_error_test.dart @@ -0,0 +1,317 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:jose_plus/jose.dart'; +import 'package:jose_plus/src/ecdh.dart'; +import 'package:jose_plus/src/util.dart'; +import 'package:test/test.dart'; + +/// Builds a bare EC JWK (public + private) with only the crypto parameters, +/// stripping the alg/use/key_ops fields that [JsonWebKey.generate] adds. +JsonWebKey _bareEcKey(String sigAlgorithm) { + final generated = JsonWebKey.generate(sigAlgorithm); + final jsonKey = Map.from(generated.toJson()) + ..remove('alg') + ..remove('use') + ..remove('key_ops'); + return JsonWebKey.fromJson(jsonKey)!; +} + +void main() { + group('ecdhKeyDataLen / ecdhAlgorithmId', () { + test('direct ECDH-ES uses the content encryption algorithm', () { + expect(ecdhAlgorithmId('ECDH-ES', 'A128GCM'), 'A128GCM'); + expect(ecdhKeyDataLen('ECDH-ES', 'A128GCM'), 128); + expect(ecdhKeyDataLen('ECDH-ES', 'A256CBC-HS512'), 512); + }); + + test('ECDH-ES+A*KW uses the wrapping algorithm key length', () { + expect(ecdhAlgorithmId('ECDH-ES+A192KW', 'A128GCM'), 'ECDH-ES+A192KW'); + expect(ecdhKeyDataLen('ECDH-ES+A192KW', 'A128GCM'), 192); + }); + + test('throws for an unsupported content/wrapping algorithm', () { + expect( + () => ecdhKeyDataLen('ECDH-ES', 'BOGUS-ENC'), + throwsA(isA()), + ); + }); + }); + + group('ecdhEsDerive', () { + test('throws when the recipient key is not an EC public key', () { + final rsaKey = JsonWebKey.generate('RS256'); + expect( + () => ecdhEsDerive( + recipientPublicKey: rsaKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA()), + ); + }); + + test('derives a key and ephemeral public key for a valid EC recipient', () { + final ecKey = _bareEcKey('ES256'); + final result = ecdhEsDerive( + recipientPublicKey: ecKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ); + expect(result.derivedKey, hasLength(16)); + expect(result.ephemeralPublicKey.keyType, 'EC'); + // Sender and recipient derive the same secret. + final recovered = ecdhEsDecrypt( + recipientPrivateKey: ecKey, + ephemeralPublicKey: result.ephemeralPublicKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ); + expect(recovered, result.derivedKey); + }); + }); + + group('ecdhEsDecrypt', () { + test('throws when the recipient key has no EC private key', () { + final rsaKey = JsonWebKey.generate('RS256'); + final ecEphemeral = ecdhEsDerive( + recipientPublicKey: _bareEcKey('ES256'), + algorithmId: 'A128GCM', + keyDataLen: 128, + ).ephemeralPublicKey; + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: rsaKey, + ephemeralPublicKey: ecEphemeral, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA()), + ); + }); + + test('throws when the ephemeral key is not an EC public key', () { + final recipient = _bareEcKey('ES256'); + final rsaEphemeral = JsonWebKey.generate('RS256'); + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: recipient, + ephemeralPublicKey: rsaEphemeral, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA()), + ); + }); + + test('throws when the ephemeral curve does not match the recipient curve', + () { + final recipient = _bareEcKey('ES256'); // P-256 + final ephemeral = ecdhEsDerive( + recipientPublicKey: _bareEcKey('ES384'), // P-384 + algorithmId: 'A128GCM', + keyDataLen: 128, + ).ephemeralPublicKey; + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: recipient, + ephemeralPublicKey: ephemeral, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA()), + ); + }); + + test('throws when the ephemeral point is not on the curve', () { + final recipient = _bareEcKey('ES256'); + // Take a valid ephemeral key on P-256, then replace its y-coordinate with + // that of a different P-256 key so the (x, y) pair no longer lies on the + // curve. + final validEphemeral = ecdhEsDerive( + recipientPublicKey: recipient, + algorithmId: 'A128GCM', + keyDataLen: 128, + ).ephemeralPublicKey; + final otherKey = _bareEcKey('ES256'); + + final tamperedJson = Map.from(validEphemeral.toJson()); + tamperedJson['y'] = otherKey['y']; + final tampered = JsonWebKey.fromJson(tamperedJson)!; + + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: recipient, + ephemeralPublicKey: tampered, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA()), + ); + }); + }); + + group('ecdhEsDerive with a JWK whose `crv` is not in curvesByName', () { + test('throws UnsupportedError before any curve arithmetic runs', () { + // `JsonWebKey.fromKeyPair` (unlike `fromJson`) does not cross-check the + // JSON `crv` against the crypto_keys `EcPublicKey.curve` it is paired + // with, so this builds a JWK whose `cryptoKeyPair.publicKey` really is + // an `EcPublicKey` (passing `ecdhEsDerive`'s initial type check) but + // whose own `['crv']` is an unrecognized string — reaching + // `curveId == null` directly, one layer earlier than the P-256K case + // above (which resolves a curveId but then fails deeper). + final normal = JsonWebKey.generate('ES256'); + final weirdKey = JsonWebKey.fromKeyPair( + keyPair: normal.cryptoKeyPair, + json: { + 'kty': 'EC', + 'crv': 'BOGUS-CURVE', + 'x': normal['x'], + 'y': normal['y'], + }, + ); + expect( + () => ecdhEsDerive( + recipientPublicKey: weirdKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Unsupported curve: BOGUS-CURVE', + )), + ); + }); + }); + + group('ecdhEsDerive with a curve unsupported by ECDH-ES key agreement', () { + test( + 'P-256K passes the curvesByName lookup but fails ECDH parameter ' + 'construction', () { + // `curvesByName` (shared with EC signing) recognizes 'P-256K', so the + // initial `curveId == null` guard in `ecdhEsDerive` passes — but + // RFC 7518 §4.6 (and this file's local `_ECCurve` table) only defines + // ECDH-ES domain parameters for P-256/P-384/P-521, so curve-parameter + // resolution fails one layer deeper, inside `_ecdhAgreement`. + final recipient = _bareEcKey('ES256K'); + expect( + () => ecdhEsDerive( + recipientPublicKey: recipient, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unsupported curve'), + )), + ); + }); + }); + + group('ecdhEsDecrypt point-at-infinity guard', () { + test( + 'a zero private scalar produces a point at infinity and throws ' + 'StateError', () { + // Scalar multiplication by 0 short-circuits `_ECPoint.multiply`'s + // double-and-add loop, returning the infinity point directly — + // independent of which public key point it would otherwise have been + // multiplied against. + final recipient = _bareEcKey('ES256'); + final ephemeral = _bareEcKey('ES256'); + final zeroScalarJson = Map.from(recipient.toJson()) + ..['d'] = base64Url.encode([0]).replaceAll('=', ''); + final zeroScalarKey = JsonWebKey.fromJson(zeroScalarJson)!; + + expect( + () => ecdhEsDecrypt( + recipientPrivateKey: zeroScalarKey, + ephemeralPublicKey: ephemeral, + algorithmId: 'A128GCM', + keyDataLen: 128, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('point at infinity'), + )), + ); + }); + }); + + group('_bigIntToBytes short-coordinate padding', () { + test('a shared secret shorter than the field size is left-zero-padded', () { + // `_ecdhAgreement`'s result.x is only guaranteed to be < curve.p, not + // exactly `fieldSize` bytes — its minimal big-endian encoding is one + // byte short whenever the top byte happens to be zero (~1/256 of + // random keys). Using random keys per-test-run would make this + // assertion flaky, so this pins a specific (private, harmless-to-leak) + // P-256 key pair, found by brute-force search, that deterministically + // reproduces a 31-byte (not 32-byte) shared secret. + final recipient = JsonWebKey.fromJson({ + 'kty': 'EC', + 'd': 'cgfsVBNkdlzuwDqmg8IwIpzQtkpCZ-kz2mzPnd913uA=', + 'x': 'rWk9DoplEjp_ljpMD_izHRMJ1z9fQJa27GBEKxyDq2c=', + 'y': '-yGzFUeWjY5ZbYdvvc5wQZZQcLBxGtYatrzd0rv6j68=', + 'crv': 'P-256', + })!; + final ephemeral = JsonWebKey.fromJson({ + 'kty': 'EC', + 'd': 'fUZk1M60Tgr_eHDvuWjxqriDvuikgNT1VGSdLeUUH8w=', + 'x': 'NFCvO3XvAkaqm1XI7dj1filojdyYKAUcv7UeTX34Z1c=', + 'y': 'lw_1QmVG-ovIpEzd0bDc5sKkr1_IJoH3AEmx7x-oRbw=', + 'crv': 'P-256', + })!; + + final result = ecdhEsDerive( + recipientPublicKey: recipient, + algorithmId: 'A128GCM', + keyDataLen: 128, + ephemeralKeyPair: ephemeral.cryptoKeyPair, + ); + // The derived key is still the full requested length regardless of the + // padding applied to the intermediate shared secret. + expect(result.derivedKey, hasLength(16)); + + // ECDH is symmetric: deriving from the other side with the same two + // points must reach the identical padding branch and agree on the key. + final recovered = ecdhEsDecrypt( + recipientPrivateKey: recipient, + ephemeralPublicKey: result.ephemeralPublicKey, + algorithmId: 'A128GCM', + keyDataLen: 128, + ); + expect(recovered, result.derivedKey); + }); + }); + + group('AES key wrap helpers', () { + test('ecdhEsWrapKey rejects a CEK whose length is not a multiple of 8', () { + // 5-byte "key" -> not a multiple of 8. + final cek = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AAAAAAA', // decodes to 5 bytes + })!; + expect( + () => ecdhEsWrapKey(Uint8List(16), cek), + throwsA(isA()), + ); + }); + + test('ecdhEsUnwrapKey rejects ciphertext that is too short', () { + expect( + () => ecdhEsUnwrapKey(Uint8List(16), [1, 2, 3]), + throwsA(isA()), + ); + }); + + test('wrap/unwrap round-trips a 16-byte content key', () { + final derivedKey = Uint8List.fromList(List.generate(16, (i) => i)); + final cek = JsonWebKey.generate('A128GCM'); + final wrapped = ecdhEsWrapKey(derivedKey, cek); + final unwrapped = ecdhEsUnwrapKey(derivedKey, wrapped); + expect(unwrapped, decodeBase64EncodedBytes(cek['k'])); + }); + }); +} diff --git a/packages/jose_plus/test/ecdh_test.dart b/packages/jose_plus/test/ecdh_test.dart new file mode 100644 index 00000000..05507260 --- /dev/null +++ b/packages/jose_plus/test/ecdh_test.dart @@ -0,0 +1,421 @@ +@Tags(['slow-web-crypto']) +library; + +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('ECDH-ES roundtrip', () { + late JsonWebKey ecKey; + + setUp(() { + ecKey = JsonWebKey.generate('ECDH-ES'); + }); + + test('ECDH-ES with A128GCM', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A128GCM!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A128GCM!'); + }); + + test('ECDH-ES with A256GCM', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A256GCM!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A256GCM!'); + }); + + test('ECDH-ES with A128CBC-HS256', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A128CBC-HS256!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A128CBC-HS256!'); + }); + + test('ECDH-ES with A256CBC-HS512', () async { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256CBC-HS512' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Hello ECDH-ES with A256CBC-HS512!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES with A256CBC-HS512!'); + }); + }); + + group('ECDH-ES+A*KW roundtrip', () { + test('ECDH-ES+A128KW with A128GCM', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A128KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A128KW') + ..stringContent = 'Hello ECDH-ES+A128KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A128KW!'); + }); + + test('ECDH-ES+A192KW with A192GCM', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A192KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A192GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A192KW') + ..stringContent = 'Hello ECDH-ES+A192KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A192KW!'); + }); + + test('ECDH-ES+A256KW with A256CBC-HS512', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A256KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A256CBC-HS512' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A256KW') + ..stringContent = 'Hello ECDH-ES+A256KW!'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Hello ECDH-ES+A256KW!'); + }); + }); + + group('ECDH-ES with different curves', () { + for (var entry in { + 'P-256': 'ES256', + 'P-384': 'ES384', + 'P-521': 'ES512', + }.entries) { + test('ECDH-ES with ${entry.key} and A128GCM', () async { + // Generate an EC key pair using the signature algorithm + final sigKey = JsonWebKey.generate(entry.value); + final keyJson = Map.from(sigKey.toJson()); + keyJson.remove('alg'); + keyJson.remove('use'); + keyJson.remove('key_ops'); + final ecKey = JsonWebKey.fromJson(keyJson)!; + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'Test with ${entry.key}'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'Test with ${entry.key}'); + }); + } + }); + + group('ECDH-ES header verification', () { + test('compact serialization contains epk in header', () { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final headerPart = compact.split('.')[0]; + final headerJson = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(headerPart)))); + + expect(headerJson['alg'], 'ECDH-ES'); + expect(headerJson['enc'], 'A128GCM'); + expect(headerJson['epk'], isNotNull); + expect(headerJson['epk']['kty'], 'EC'); + expect(headerJson['epk']['crv'], isNotNull); + expect(headerJson['epk']['x'], isNotNull); + expect(headerJson['epk']['y'], isNotNull); + // Ephemeral public key should NOT contain private key + expect(headerJson['epk']['d'], isNull); + }); + + test('ECDH-ES direct has empty encrypted key', () { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parts = compact.split('.'); + expect(parts[1], isEmpty); + }); + + test('ECDH-ES+A128KW has non-empty encrypted key', () { + final ecKey = JsonWebKey.generate('ECDH-ES+A128KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A128KW') + ..stringContent = 'test'; + + final jwe = builder.build(); + final compact = jwe.toCompactSerialization(); + + final parts = compact.split('.'); + expect(parts[1], isNotEmpty); + }); + }); + + group('ECDH-ES with JSON serialization', () { + test('JWE JSON roundtrip with ECDH-ES', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'JSON serialization test'; + + final jwe = builder.build(); + final jsonSerialization = jwe.toJson(); + + final parsed = JsonWebEncryption.fromJson(jsonSerialization); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'JSON serialization test'); + }); + }); + + group('ECDH-ES in JWT', () { + test('Encrypted JWT with ECDH-ES', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final claims = JsonWebTokenClaims.fromJson({ + 'sub': '1234567890', + 'name': 'John Doe', + 'admin': true, + 'iat': 1516239022, + }); + + final builder = JsonWebSignatureBuilder() + ..jsonContent = claims.toJson() + ..addRecipient(JsonWebKey.fromJson({'kty': 'oct', 'k': ''})!, + algorithm: 'none'); + final innerJws = builder.build(); + + final jweBuilder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = innerJws.toCompactSerialization(); + + final jwe = jweBuilder.build(); + final compact = jwe.toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + final innerContent = payload.stringContent; + // The decrypted content is the inner JWS compact serialization + // which contains the base64url-encoded claims + final jwsParts = innerContent.split('.'); + final claimsJson = + utf8.decode(base64Url.decode(base64Url.normalize(jwsParts[1]))); + expect(claimsJson, contains('John Doe')); + }); + }); + + group('ECDH-ES with apu/apv (RFC 7518 4.6.1.2/4.6.1.3)', () { + // `apu`/`apv` are OPTIONAL, but when present in the header they are part + // of the Concat KDF OtherInfo (PartyUInfo/PartyVInfo, RFC 7518 4.6.2). + String b64(String s) => + base64Url.encode(utf8.encode(s)).replaceAll('=', ''); + + Map protectedHeaderOf(String compact) => json.decode(utf8 + .decode(base64Url.decode(base64Url.normalize(compact.split('.')[0])))); + + for (final enc in ['A128GCM', 'A256GCM', 'A128CBC-HS256']) { + test('ECDH-ES roundtrip with apu and apv ($enc)', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = enc + ..setProtectedHeader('apu', b64('Alice')) + ..setProtectedHeader('apv', b64('Bob')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'agreement info matters'; + + final compact = builder.build().toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'agreement info matters'); + }); + } + + test('ECDH-ES+A128KW roundtrip with apu and apv', () async { + final ecKey = JsonWebKey.generate('ECDH-ES+A128KW'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..setProtectedHeader('apu', b64('Alice')) + ..setProtectedHeader('apv', b64('Bob')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES+A128KW') + ..stringContent = 'key wrapping with agreement info'; + + final compact = builder.build().toCompactSerialization(); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'key wrapping with agreement info'); + }); + + test('roundtrip with only apu', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..setProtectedHeader('apu', b64('Alice')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'only apu'; + + final compact = builder.build().toCompactSerialization(); + final header = protectedHeaderOf(compact); + expect(header['apu'], b64('Alice')); + expect(header.containsKey('apv'), isFalse); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final payload = await parsed.getPayload(JsonWebKeyStore()..addKey(ecKey)); + expect(payload.stringContent, 'only apu'); + }); + + test('roundtrip with only apv', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..setProtectedHeader('apv', b64('Bob')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'only apv'; + + final compact = builder.build().toCompactSerialization(); + final header = protectedHeaderOf(compact); + expect(header['apv'], b64('Bob')); + expect(header.containsKey('apu'), isFalse); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final payload = await parsed.getPayload(JsonWebKeyStore()..addKey(ecKey)); + expect(payload.stringContent, 'only apv'); + }); + + test('apu and apv survive into the protected header unchanged', () { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..setProtectedHeader('apu', b64('Alice')) + ..setProtectedHeader('apv', b64('Bob')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'test'; + + final compact = builder.build().toCompactSerialization(); + final header = protectedHeaderOf(compact); + + expect(header['apu'], b64('Alice')); + expect(header['apv'], b64('Bob')); + expect(utf8.decode(base64Url.decode(base64Url.normalize(header['apu']))), + 'Alice'); + expect(utf8.decode(base64Url.decode(base64Url.normalize(header['apv']))), + 'Bob'); + // apu/apv are integrity protected, never in an unprotected header + expect(builder.build().toJson().containsKey('unprotected'), isFalse); + }); + + test('apu/apv are honoured on the non-compact path too', () async { + // Setting `aad` forces the JSON serialization branch, where the + // protected header is not merged with the per-recipient parameters. + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..additionalAuthenticatedData = utf8.encode('extra aad') + ..setProtectedHeader('apu', b64('Alice')) + ..setProtectedHeader('apv', b64('Bob')) + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'non-compact with agreement info'; + + final parsed = JsonWebEncryption.fromJson(builder.build().toJson()); + final payload = await parsed.getPayload(JsonWebKeyStore()..addKey(ecKey)); + expect(payload.stringContent, 'non-compact with agreement info'); + }); + + test('absent apu/apv keep the previous behaviour', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..addRecipient(ecKey, algorithm: 'ECDH-ES') + ..stringContent = 'no agreement info'; + + final compact = builder.build().toCompactSerialization(); + final header = protectedHeaderOf(compact); + expect(header.containsKey('apu'), isFalse); + expect(header.containsKey('apv'), isFalse); + + final parsed = JsonWebEncryption.fromCompactSerialization(compact); + final payload = await parsed.getPayload(JsonWebKeyStore()..addKey(ecKey)); + expect(payload.stringContent, 'no agreement info'); + }); + }); +} diff --git a/packages/jose_plus/test/jose_test.dart b/packages/jose_plus/test/jose_test.dart new file mode 100644 index 00000000..e085beea --- /dev/null +++ b/packages/jose_plus/test/jose_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JoseHeader', () { + test('exposes header parameters', () { + final header = JoseHeader.fromJson({ + 'alg': 'RS256', + 'jku': 'https://example.com/keys.json', + 'kid': 'key-1', + 'typ': 'JWT', + 'cty': 'example', + 'enc': 'A128GCM', + 'zip': 'DEF', + 'apu': 'dGVzdA', + 'apv': 'dGVzdA', + }); + + expect(header.algorithm, 'RS256'); + expect(header.jwkSetUrl, Uri.parse('https://example.com/keys.json')); + expect(header.keyId, 'key-1'); + expect(header.type, 'JWT'); + expect(header.contentType, 'example'); + expect(header.encryptionAlgorithm, 'A128GCM'); + expect(header.compressionAlgorithm, 'DEF'); + expect(header.agreementPartyUInfo, 'dGVzdA'); + expect(header.agreementPartyVInfo, 'dGVzdA'); + }); + + test('parses an embedded jwk header parameter', () { + final header = JoseHeader.fromJson({ + 'alg': 'ES256', + 'jwk': { + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y': 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + }, + }); + + final jwk = header.jsonWebKey; + expect(jwk, isNotNull); + expect(jwk!.keyType, 'EC'); + }); + + test('fromBase64EncodedString decodes an encoded header', () { + final encoded = base64Url + .encode(utf8.encode(json.encode({'alg': 'HS256'}))) + .replaceAll('=', ''); + final header = JoseHeader.fromBase64EncodedString(encoded); + expect(header.algorithm, 'HS256'); + }); + + test('critical reads a `crit` header parameter as a string list', () { + final header = JoseHeader.fromJson({ + 'alg': 'RS256', + 'crit': ['exp'], + }); + + expect(header.critical, ['exp']); + }); + + test('critical is null when `crit` is absent', () { + final header = JoseHeader.fromJson({'alg': 'RS256'}); + + expect(header.critical, isNull); + }); + }); + + group('JoseObject.fromJson', () { + test('builds a JsonWebSignature from a payload-bearing map', () { + final obj = JoseObject.fromJson({ + 'payload': 'eyJpc3MiOiJqb2UifQ', + 'protected': 'eyJhbGciOiJub25lIn0', + 'signature': '', + }); + expect(obj, isA()); + }); + + test('builds a JsonWebEncryption from a ciphertext-bearing map', () { + final obj = JoseObject.fromJson({ + 'protected': 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIn0', + 'iv': 'AAAAAAAAAAAAAAAA', + 'ciphertext': 'AAAA', + 'tag': 'AAAAAAAAAAAAAAAAAAAAAA', + }); + expect(obj, isA()); + }); + + test('throws for a map that is neither JWS nor JWE', () { + expect( + () => JoseObject.fromJson({'foo': 'bar'}), + throwsA(isA()), + ); + }); + }); + + group('JoseObject.fromCompactSerialization', () { + test('dispatches to JWS for 3-part serializations', () { + const jws = 'eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UifQ.'; + expect(JoseObject.fromCompactSerialization(jws), isA()); + }); + + test('throws for an invalid number of parts', () { + expect( + () => JoseObject.fromCompactSerialization('only.two'), + throwsA(isA()), + ); + expect( + () => JoseObject.fromCompactSerialization('a.b.c.d'), + throwsA(isA()), + ); + }); + }); + + group('JoseObject.commonProtectedHeader', () { + test('contains protected parameters shared across signatures', () { + // A general JSON serialization with two signatures both protecting + // the same `alg`. + final jws = JsonWebSignature.fromJson({ + 'payload': 'eyJpc3MiOiJqb2UifQ', + 'signatures': [ + {'protected': 'eyJhbGciOiJSUzI1NiJ9', 'signature': 'AAAA'}, + {'protected': 'eyJhbGciOiJSUzI1NiJ9', 'signature': 'BBBB'}, + ], + }); + + expect(jws.commonProtectedHeader.algorithm, 'RS256'); + }); + }); + + group('JoseObjectBuilder', () { + test('content setter accepts a byte list payload', () { + final builder = JsonWebSignatureBuilder()..content = [1, 2, 3]; + expect(builder.payload!.data, [1, 2, 3]); + }); + + test('content setter accepts a String payload', () { + final builder = JsonWebSignatureBuilder()..content = 'hi'; + expect(utf8.decode(builder.payload!.data), 'hi'); + }); + + test('content setter accepts a JSON payload', () { + final builder = JsonWebSignatureBuilder()..content = {'a': 1}; + expect(json.decode(utf8.decode(builder.payload!.data)), {'a': 1}); + }); + + test('mediaType getter and setter round-trip through the cty header', () { + final builder = JsonWebSignatureBuilder()..mediaType = 'JWT'; + expect(builder.mediaType, 'JWT'); + expect(builder.protectedHeader['cty'], 'JWT'); + }); + + test('payload is null until content is set', () { + expect(JsonWebSignatureBuilder().payload, isNull); + }); + }); + + group('JoseException', () { + test('toString includes the message', () { + final e = JoseException('something went wrong'); + expect(e.message, 'something went wrong'); + expect(e.toString(), 'JoseException: something went wrong'); + }); + }); +} diff --git a/packages/jose_plus/test/jwa_error_test.dart b/packages/jose_plus/test/jwa_error_test.dart new file mode 100644 index 00000000..57a74a43 --- /dev/null +++ b/packages/jose_plus/test/jwa_error_test.dart @@ -0,0 +1,100 @@ +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +/// A [JsonWebAlgorithm] whose [jwkFromCryptoKeyPair] always returns `null`, +/// simulating a mismatch between [JsonWebAlgorithm.generateCryptoKeyPair] +/// (which always succeeds for the 4 known `type`s) and +/// [JsonWebAlgorithm.jwkFromCryptoKeyPair] (which the base implementation +/// also always populates for those same 4 types) — a "should never happen" +/// combination that [JsonWebAlgorithm.generateRandomKey] still guards +/// against. +class _AlwaysNullJwk extends JsonWebAlgorithm { + const _AlwaysNullJwk() + : super('X-NULL', type: 'oct', use: 'sig', minKeyBitLength: 8); + + @override + JsonWebKey? jwkFromCryptoKeyPair(KeyPair keyPair) => null; +} + +void main() { + group('JsonWebAlgorithm.getByName', () { + test('returns the algorithm for a known name', () { + expect(JsonWebAlgorithm.getByName('HS256'), JsonWebAlgorithm.hs256); + expect(JsonWebAlgorithm.getByName('RS256').type, 'RSA'); + }); + + test('throws UnsupportedError for an unknown name', () { + expect( + () => JsonWebAlgorithm.getByName('NOPE-999'), + throwsA(isA()), + ); + }); + }); + + group('JsonWebAlgorithm.find', () { + test('filters by operation', () { + final signers = JsonWebAlgorithm.find(operation: 'sign').toList(); + expect(signers, isNotEmpty); + expect(signers.every((a) => a.keyOperations.contains('sign')), isTrue); + }); + + test('filters by key type', () { + final ecAlgs = JsonWebAlgorithm.find(keyType: 'EC').toList(); + expect(ecAlgs, isNotEmpty); + expect(ecAlgs.every((a) => a.type == 'EC'), isTrue); + }); + + test('filters by both operation and key type', () { + final result = + JsonWebAlgorithm.find(operation: 'sign', keyType: 'RSA').toList(); + expect(result, contains(JsonWebAlgorithm.rs256)); + expect(result, isNot(contains(JsonWebAlgorithm.es256))); + }); + }); + + group('JsonWebAlgorithm.keyOperations', () { + test('maps each supported use to its operations', () { + expect(JsonWebAlgorithm.hs256.keyOperations, ['sign', 'verify']); + expect(JsonWebAlgorithm.a128kw.keyOperations, ['wrapKey', 'unwrapKey']); + expect(JsonWebAlgorithm.a128gcm.keyOperations, ['encrypt', 'decrypt']); + }); + + test('throws for an unsupported use', () { + const bogus = JsonWebAlgorithm('X', type: 'oct', use: 'mystery'); + expect(() => bogus.keyOperations, throwsA(isA())); + }); + }); + + group('JsonWebAlgorithm.generateCryptoKeyPair', () { + test('throws for an unsupported key type', () { + const bogus = JsonWebAlgorithm('X', type: 'weird-type', use: 'sig'); + expect( + () => bogus.generateCryptoKeyPair(), + throwsA(isA()), + ); + }); + }); + + group('JsonWebAlgorithm key length validation', () { + test('rejects a key length below the algorithm minimum', () { + expect( + () => JsonWebKey.generate('HS256', keyBitLength: 8), + throwsA(isA()), + ); + }); + + test('accepts a key length at or above the minimum', () { + final key = JsonWebKey.generate('HS256', keyBitLength: 256); + expect(key.keyType, 'oct'); + }); + }); + + group('JsonWebAlgorithm.generateRandomKey', () { + test('throws UnimplementedError when jwkFromCryptoKeyPair returns null', + () { + const alg = _AlwaysNullJwk(); + expect(() => alg.generateRandomKey(), throwsA(isA())); + }); + }); +} diff --git a/packages/jose_plus/test/jwa_test.dart b/packages/jose_plus/test/jwa_test.dart new file mode 100644 index 00000000..76b697cc --- /dev/null +++ b/packages/jose_plus/test/jwa_test.dart @@ -0,0 +1,51 @@ +@Tags(['slow-web-crypto']) +library; + +import 'dart:convert'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWA', () { + test('Generating keys', () { + var data = utf8.encode('hello world'); + + for (var a in JsonWebAlgorithm.allAlgorithms) { + print(a.name); + var keyPair = a.generateCryptoKeyPair(); + + var key = a.jwkFromCryptoKeyPair(keyPair); + if (key == null) { + throw UnimplementedError('Unkown key'); + } + if (a.type == 'oct') { + expect((key.cryptoKeyPair.publicKey as SymmetricKey).keyValue, + (keyPair.publicKey as SymmetricKey).keyValue); + } else { + expect(key.cryptoKeyPair.publicKey, keyPair.publicKey); + expect(key.cryptoKeyPair.privateKey, keyPair.privateKey); + } + + switch (a.use) { + case 'sig': + expect(key.keyOperations, {'sign', 'verify'}); + var signature = key.sign(data); + expect(key.verify(data, signature), isTrue); + break; + case 'enc': + expect(key.keyOperations, {'encrypt', 'decrypt'}); + var encrypted = key.encrypt(data); + expect( + key.decrypt(encrypted.data, + initializationVector: encrypted.initializationVector, + additionalAuthenticatedData: + encrypted.additionalAuthenticatedData, + authenticationTag: encrypted.authenticationTag), + data); + } + } + }); + }); +} diff --git a/packages/jose_plus/test/jwe_error_test.dart b/packages/jose_plus/test/jwe_error_test.dart new file mode 100644 index 00000000..e082b766 --- /dev/null +++ b/packages/jose_plus/test/jwe_error_test.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +// Re-encodes a compact JWE after mutating its protected header JSON. +// (Top-level so both the existing ECDH-ES group and the new decrypt-time +// header-tampering tests below can share it.) +String _withMutatedHeader( + String compact, + void Function(Map) mutate, +) { + final parts = compact.split('.'); + final header = + json.decode(utf8.decode(base64Url.decode(base64Url.normalize(parts[0])))) + as Map; + mutate(header); + parts[0] = + base64Url.encode(utf8.encode(json.encode(header))).replaceAll('=', ''); + return parts.join('.'); +} + +void main() { + final octKw = JsonWebKey.fromJson({ + 'kty': 'oct', + 'alg': 'A128KW', + 'k': 'GawgguFyGrWKav7AX4VKUg', + })!; + + group('JsonWebEncryption.fromCompactSerialization', () { + test('throws when the serialization does not have five parts', () { + expect( + () => JsonWebEncryption.fromCompactSerialization('a.b.c'), + throwsA(isA()), + ); + }); + }); + + group('JsonWebEncryption.toCompactSerialization guards', () { + test('throws when there are multiple recipients', () { + final key2 = JsonWebKey.fromJson({ + 'kty': 'oct', + 'alg': 'A128KW', + 'k': 'AAECAwQFBgcICQoLDA0ODw', + })!; + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'multi' + ..addRecipient(octKw, algorithm: 'A128KW') + ..addRecipient(key2, algorithm: 'A128KW')) + .build(); + expect(() => jwe.toCompactSerialization(), throwsStateError); + }); + + test('throws when a shared unprotected header is present', () { + // Using additionalAuthenticatedData forces the non-compact build path, + // which produces a shared unprotected header. + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'aad-forces-json' + ..additionalAuthenticatedData = utf8.encode('extra') + ..addRecipient(octKw, algorithm: 'A128KW')) + .build(); + expect(() => jwe.toCompactSerialization(), throwsStateError); + }); + + test('throws when only a per-recipient unprotected header is present', () { + // Build a valid compact JWE, then re-serialize it as flattened JSON with + // a per-recipient `header` (and no shared `unprotected`). + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final compact = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'hi' + ..addRecipient(dirKey, algorithm: 'dir')) + .build() + .toCompactSerialization() + .split('.'); + final flattened = JsonWebEncryption.fromJson({ + 'protected': compact[0], + 'header': {'kid': 'x'}, + 'iv': compact[2], + 'ciphertext': compact[3], + 'tag': compact[4], + }); + expect(() => flattened.toCompactSerialization(), throwsStateError); + }); + }); + + group('JsonWebEncryptionBuilder.build guards', () { + test('throws when the encryption algorithm is null', () { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = null + ..content = 'x' + ..addRecipient(octKw, algorithm: 'A128KW'); + expect(() => builder.build(), throwsStateError); + }); + + test('throws when the encryption algorithm is `none`', () { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'none' + ..content = 'x' + ..addRecipient(octKw, algorithm: 'A128KW'); + expect(() => builder.build(), throwsStateError); + }); + + test('throws when there are no recipients', () { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'x'; + expect(() => builder.build(), throwsStateError); + }); + + test('throws when no payload has been set', () { + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..addRecipient(octKw, algorithm: 'A128KW'); + expect(() => builder.build(), throwsStateError); + }); + + test('throws when using `dir` with more than one recipient', () { + final k1 = JsonWebKey.generate('A128CBC-HS256'); + final k2 = JsonWebKey.generate('A128CBC-HS256'); + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'x' + ..addRecipient(k1, algorithm: 'dir') + ..addRecipient(k2, algorithm: 'dir'); + expect(() => builder.build(), throwsStateError); + }); + + test('throws when using ECDH-ES direct with more than one recipient', () { + final k1 = JsonWebKey.generate('ECDH-ES'); + final k2 = JsonWebKey.generate('ECDH-ES'); + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..content = 'x' + ..addRecipient(k1, algorithm: 'ECDH-ES') + ..addRecipient(k2, algorithm: 'ECDH-ES'); + expect(() => builder.build(), throwsStateError); + }); + + test('throws JoseException for an unsupported compression algorithm', () { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..compressionAlgorithm = 'GZIP' + ..content = 'x' + ..addRecipient(dirKey, algorithm: 'dir'); + expect(() => builder.build(), throwsA(isA())); + }); + }); + + group('JsonWebEncryption with DEF compression', () { + test('round-trips a payload through deflate/inflate', () async { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final payload = 'compress me ' * 20; + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..compressionAlgorithm = 'DEF' + ..content = payload + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + + final parsed = JsonWebEncryption.fromCompactSerialization( + jwe.toCompactSerialization()); + final keyStore = JsonWebKeyStore()..addKey(dirKey); + final result = await parsed.getPayload(keyStore); + expect(result.stringContent, payload); + }); + }); + + group('ECDH-ES decryption error paths', () { + test('fails when the ephemeral public key is missing from the header', + () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + final compact = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..content = 'secret' + ..addRecipient(ecKey, algorithm: 'ECDH-ES')) + .build() + .toCompactSerialization(); + + final tampered = _withMutatedHeader(compact, (h) => h.remove('epk')); + final parsed = JsonWebEncryption.fromCompactSerialization(tampered); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + // The missing-epk JoseException is swallowed per-recipient; the overall + // decryption then fails. + expect(parsed.getPayload(keyStore), throwsA(isA())); + }); + + test('processes apu/apv agreement info during key derivation', () async { + final ecKey = JsonWebKey.generate('ECDH-ES'); + final compact = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..content = 'secret' + ..addRecipient(ecKey, algorithm: 'ECDH-ES')) + .build() + .toCompactSerialization(); + + // Inject apu/apv that the sender did not use; derivation will run the + // apu/apv branches and produce a different key, so decryption ultimately + // fails. + final tampered = _withMutatedHeader(compact, (h) { + h['apu'] = 'QWxpY2U'; // "Alice" + h['apv'] = 'Qm9i'; // "Bob" + }); + final parsed = JsonWebEncryption.fromCompactSerialization(tampered); + final keyStore = JsonWebKeyStore()..addKey(ecKey); + expect(parsed.getPayload(keyStore), throwsA(isA())); + }); + }); + + group('getPayloadFor decrypt-time header guards', () { + // These two guards inside `getPayloadFor` are unreachable through the + // normal `getPayload` -> `findJsonWebKeys` -> `getPayloadFor` pipeline: + // - `enc: "none"`: `JsonWebKeyStore._isValidKeyFor` calls + // `key.usableForAlgorithm(header.encryptionAlgorithm)` before any key + // reaches `getPayloadFor`; since no real key's own `alg` is ever + // "none", that call returns false and no key is ever yielded. + // - an unsupported `zip`: the JWE protected header is integrity-bound + // into the AEAD `aad`, so tampering the parsed compact serialization's + // header (as done for the ECDH-ES cases above) also changes the `aad` + // used to re-derive it, which fails authentication before the + // compression switch is ever reached — and the builder already + // rejects an unsupported `zip` at build() time, so a genuinely valid + // JWE with a bad `zip` can never be constructed either. + // + // `getPayloadFor` is `@protected` only as an analyzer hint (for + // subclassers), not a language-enforced restriction, so both guards are + // exercised directly with a hand-built [JoseHeader] — the only way to + // reach them without also breaking the AEAD tag. + test('fails when the protected header declares enc="none"', () { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'hi' + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + final header = JoseHeader.fromJson({'alg': 'dir', 'enc': 'none'}); + expect( + // ignore: invalid_use_of_protected_member + () => jwe.getPayloadFor(dirKey, header, jwe.recipients.first), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('cannot be `none`'), + )), + ); + }); + + test('fails when the protected header declares an unsupported zip alg', () { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..content = 'hi' + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + final header = JoseHeader.fromJson( + {'alg': 'dir', 'enc': 'A128CBC-HS256', 'zip': 'BOGUS'}, + ); + expect( + // ignore: invalid_use_of_protected_member + () => jwe.getPayloadFor(dirKey, header, jwe.recipients.first), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Unsupported compression algorithm BOGUS'), + )), + ); + }); + }); + + group('JsonWebEncryptionBuilder.build with a JWK of an unrecognized `kty`', + () { + test('throws UnimplementedError for direct encryption', () { + // `JsonWebKey.fromKeyPair` (unlike `fromJson`) does not validate `kty` + // against the known set, so this builds a JWK whose `toJson()` carries + // a `kty` that `KeyPair.fromJwk` cannot recognize when the builder + // re-parses it for the `dir` (direct encryption) code path. + final normal = JsonWebKey.generate('A128GCM'); + final weirdKey = JsonWebKey.fromKeyPair( + keyPair: normal.cryptoKeyPair, + json: {'kty': 'weird', 'k': normal['k']}, + ); + final builder = JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128GCM' + ..content = 'hi' + ..addRecipient(weirdKey, algorithm: 'dir'); + expect(() => builder.build(), throwsA(isA())); + }); + }); + + group('JsonWebEncryption with additional authenticated data', () { + test('toJson exposes aad and the payload round-trips via JSON', () async { + final dirKey = JsonWebKey.generate('A128CBC-HS256'); + final payload = 'protected by aad'; + final jwe = (JsonWebEncryptionBuilder() + ..encryptionAlgorithm = 'A128CBC-HS256' + ..additionalAuthenticatedData = utf8.encode('bound-data') + ..content = payload + ..addRecipient(dirKey, algorithm: 'dir')) + .build(); + + final asJson = jwe.toJson(); + expect(asJson['aad'], isNotNull); + + final parsed = JsonWebEncryption.fromJson(asJson); + final keyStore = JsonWebKeyStore()..addKey(dirKey); + final result = await parsed.getPayload(keyStore); + expect(result.stringContent, payload); + }); + }); +} diff --git a/packages/jose_plus/test/jwe_test.dart b/packages/jose_plus/test/jwe_test.dart new file mode 100644 index 00000000..12eb4d18 --- /dev/null +++ b/packages/jose_plus/test/jwe_test.dart @@ -0,0 +1,459 @@ +@Tags(['slow-web-crypto']) +library; + +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWE Examples from RFC7516', () { + group('Example JWE using RSAES-OAEP and AES GCM', () { + _doTests( + 'The true sign of intelligence is not knowledge but imagination.', + JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': 'oahUIoWw0K0usKNuOR6H4wkf4oBUXHTxRvgb48E-BVvxkeDNjbC4he8rUW' + 'cJoZmds2h7M70imEVhRU5djINXtqllXI4DFqcI1DgjT9LewND8MW2Krf3S' + 'psk_ZkoFnilakGygTwpZ3uesH-PFABNIUYpOiN15dsQRkgr0vEhxN92i2a' + 'sbOenSZeyaxziK72UwxrrKoExv6kc5twXTq4h-QChLOln0_mtUZwfsRaMS' + 'tPs6mS6XrgxnxbWhojf663tuEQueGC-FCMfra36C9knDFGzKsNa7LZK2dj' + 'YgyD3JR_MB_4NUJW_TqOQtwHYbxevoJArm-L5StowjzGy-_bq6Gw', + 'e': 'AQAB', + 'd': 'kLdtIj6GbDks_ApCSTYQtelcNttlKiOyPzMrXHeI-yk1F7-kpDxY4-WY5N' + 'WV5KntaEeXS1j82E375xxhWMHXyvjYecPT9fpwR_M9gV8n9Hrh2anTpTD9' + '3Dt62ypW3yDsJzBnTnrYu1iwWRgBKrEYY46qAZIrA2xAwnm2X7uGR1hghk' + 'qDp0Vqj3kbSCz1XyfCs6_LehBwtxHIyh8Ripy40p24moOAbgxVw3rxT_vl' + 't3UVe4WO3JkJOzlpUf-KTVI2Ptgm-dARxTEtE-id-4OJr0h-K-VFs3VSnd' + 'VTIznSxfyrj8ILL6MG_Uv8YAu7VILSB3lOW085-4qE3DzgrTjgyQ', + 'p': '1r52Xk46c-LsfB5P442p7atdPUrxQSy4mti_tZI3Mgf2EuFVbUoDBvaRQ-' + 'SWxkbkmoEzL7JXroSBjSrK3YIQgYdMgyAEPTPjXv_hI2_1eTSPVZfzL0lf' + 'fNn03IXqWF5MDFuoUYE0hzb2vhrlN_rKrbfDIwUbTrjjgieRbwC6Cl0', + 'q': 'wLb35x7hmQWZsWJmB_vle87ihgZ19S8lBEROLIsZG4ayZVe9Hi9gDVCOBm' + 'UDdaDYVTSNx_8Fyw1YYa9XGrGnDew00J28cRUoeBB_jKI1oma0Orv1T9aX' + 'IWxKwd4gvxFImOWr3QRL9KEBRzk2RatUBnmDZJTIAfwTs0g68UZHvtc', + 'dp': 'ZK-YwE7diUh0qR1tR7w8WHtolDx3MZ_OTowiFvgfeQ3SiresXjm9gZ5KL' + 'hMXvo-uz-KUJWDxS5pFQ_M0evdo1dKiRTjVw_x4NyqyXPM5nULPkcpU827' + 'rnpZzAJKpdhWAgqrXGKAECQH0Xt4taznjnd_zVpAmZZq60WPMBMfKcuE', + 'dq': 'Dq0gfgJ1DdFGXiLvQEZnuKEN0UUmsJBxkjydc3j4ZYdBiMRAy86x0vHCj' + 'ywcMlYYg4yoC4YZa9hNVcsjqA3FeiL19rk8g6Qn29Tt0cj8qqyFpz9vNDB' + 'UfCAiJVeESOjJDZPYHdHY8v1b-o-Z2X5tvLx-TCekf7oxyeKDUqKWjis', + 'qi': 'VIMpMYbPf47dT1w_zDUXfPimsSegnMOA1zTaX7aGk_8urY6R8-ZW1FxU7' + 'AlWAyLWybqq6t16VFd7hQd0y6flUK4SlOydB61gwanOsXGOAOv82cHq0E3' + 'eL4HrtZkUuKvnPrMnsUUFlfUdybVzxyjz9JF_XyaY14ardLSjf4L_FNY' + }), + 'eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.' + 'OKOawDo13gRp2ojaHV7LFpZcgV7T6DVZKTyKOMTYUmKoTCVJRgckCL9kiMT03JGe' + 'ipsEdY3mx_etLbbWSrFr05kLzcSr4qKAq7YN7e9jwQRb23nfa6c9d-StnImGyFDb' + 'Sv04uVuxIp5Zms1gNxKKK2Da14B8S4rzVRltdYwam_lDp5XnZAYpQdb76FdIKLaV' + 'mqgfwX7XWRxv2322i-vDxRfqNzo_tETKzpVLzfiwQyeyPGLBIO56YJ7eObdv0je8' + '1860ppamavo35UgoRdbYaBcoh9QcfylQr66oc6vFWXRcZ_ZT2LawVCWTIy3brGPi' + '6UklfCpIMfIjf7iGdXKHzg.' + '48V1_ALb6US04U3b.' + '5eym8TW_c8SuK0ltJ3rpYIzOeDQz7TALvtu6UG9oMo4vpzs9tX_EFShS8iB7j6ji' + 'SdiwkIr3ajwQzaBtQD_A.' + 'XFBoMYUZodetZdvTiFvSkQ'); + }); + group('Example JWE using RSAES-PKCS1-v1_5 and AES_128_CBC_HMAC_SHA_256', + () { + _doTests( + 'Live long and prosper.', + JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ), + 'eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.' + 'UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm' + '1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7Pc' + 'HALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIF' + 'NPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8' + 'rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv' + '-B3oWh2TbqmScqXMR4gp_A.' + 'AxY8DCtDaGlsbGljb3RoZQ.' + 'KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY.' + '9hH0vgRfYgPnAHOd8stkvw'); + }); + group('Example JWE Using AES Key Wrap and AES_128_CBC_HMAC_SHA_256', () { + _doTests( + 'Live long and prosper.', + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}), + 'eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.' + '6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ.' + 'AxY8DCtDaGlsbGljb3RoZQ.' + 'KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY.' + 'U0m_YmjN04DJvceFICbCVQ'); + }); + group('Example JWE Using General JWE JSON Serialization', () { + var keys = [ + JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'kid': '2011-04-29', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + ), + JsonWebKey.fromJson( + {'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg', 'kid': '7'}), + ]; + var plaintext = 'Live long and prosper.'; + var encoded = { + 'protected': 'eyJlbmMiOiJBMTI4Q0JDLUhTMjU2In0', + 'unprotected': {'jku': 'https://server.example.com/keys.jwks'}, + 'recipients': [ + { + 'header': {'alg': 'RSA1_5', 'kid': '2011-04-29'}, + 'encrypted_key': 'UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-' + 'kFm1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKx' + 'GHZ7PcHALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3' + 'YvkkysZIFNPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPh' + 'cCdZ6XDP0_F8rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPg' + 'wCp6X-nZZd9OHBv-B3oWh2TbqmScqXMR4gp_A' + }, + { + 'header': {'alg': 'A128KW', 'kid': '7'}, + 'encrypted_key': + '6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ' + } + ], + 'iv': 'AxY8DCtDaGlsbGljb3RoZQ', + 'ciphertext': 'KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY', + 'tag': 'Mz-VPPyU4RlcuYv1IwIvzw' + }; + + group('Example JWE Using General JWE JSON Serialization: Recipient 1', + () { + _doTests(plaintext, keys[0], encoded); + }); + group('Example JWE Using General JWE JSON Serialization: Recipient 2', + () { + _doTests(plaintext, keys[1], encoded); + }); + }); + group('Example JWE Using Flattened JWE JSON Serialization', () { + _doTests( + 'Live long and prosper.', + JsonWebKey.fromJson( + {'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg', 'kid': '7'}), + { + 'protected': 'eyJlbmMiOiJBMTI4Q0JDLUhTMjU2In0', + 'unprotected': {'jku': 'https://server.example.com/keys.jwks'}, + 'header': {'alg': 'A128KW', 'kid': '7'}, + 'encrypted_key': + '6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ', + 'iv': 'AxY8DCtDaGlsbGljb3RoZQ', + 'ciphertext': 'KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY', + 'tag': 'Mz-VPPyU4RlcuYv1IwIvzw' + }); + }); + }); + group('Special algorithms JWE', () { + test('Key wrapping with `dir`', () async { + var payload = 'I am disguised'; + var builder = JsonWebEncryptionBuilder()..content = payload; + + var key = JsonWebKey.generate(builder.encryptionAlgorithm); + var keyStore = JsonWebKeyStore()..addKey(key); + + builder.addRecipient(key, algorithm: 'dir'); + var jwe = builder.build(); + + jwe = JsonWebEncryption.fromCompactSerialization( + jwe.toCompactSerialization()); + + expect((await jwe.getPayload(keyStore)).stringContent, payload); + }); + }); + group('JWE Examples from jose4j', () { + group('Example JWE Using RSAES-OAEP-256 and A128CBC-HS256', () { + _doTests( + "Well, as of this moment, they're on DOUBLE SECRET PROBATION!", + JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': '2cQJH1f6yF9DcGa8Cmbnhn4LHLs5L6kNb2rxkrNFZArJLRaKvaC3tMCKZ8Zg' + 'IpO9bVMPx5UMjJoaf7p9O5BSApVqA2J10fUbdSIomCcDwvGo0eyhty0D' + 'ILLWTMXzGEVM3BXzuJQoeDkuUCXXcCwA4Msyyd2OHVu-pB2OrGv6fcjH' + 'wjINty3UoKm08lCvAevBKHsuA-FFwQII9bycvRx5wRqFUjdMAyiOmLYB' + 'HBaJSi11g3HVexMcb29v14PSlVzdGUMN8oboa-zcIyaPrIiczLqAkSXQ' + 'NdEFHrjsJHfFeNMfOblLM7icKN_tyWujYeItt4kqUIimPn5dHjwgcQYE7w', + 'e': 'AQAB', + 'd': 'dyUz3ItVceX1Tv1WqtZMnKA_0jN5gWMcL7ayf5JISAlCssGfnUre2C10TH0U' + 'QjbVMIh-nLMnD5KNJw9Qz5MR28oGG932Gq7hm__ZeA34l-OCe4Ddpgwh' + 'pvVSHOU9MS1RdSUpmPavAcA_X6ikrAHXZSaoHhxzUgrNTpvBYQMfJUv_' + '492fStIseQ9rwAMOpCWOiWMZOQm3KJVTLLunXdKf_UxmzmKXYKYZWke3' + 'AWIzUqnOfqIjfDTMunF4UWU0zKlhcsaQNmYMVrJGajD1bJdy_dbUU3LE' + '8sx-bdkUI6oBk-sFtTTVyVdQcetG9kChJ5EnY5R6tt_4_xFG5kxzTo6qaQ', + 'p': '7yQmgE60SL7QrXpAJhChLgKnXWi6C8tVx1lA8FTpphpLaCtK-HbgBVHC' + 'prC2CfaM1mxFJZahxgFjC9ehuV8OzMNyFs8kekS82EsQGksi8HJP' + 'xyR1fU6ATa36ogPG0nNaqm3EDmYyjowhntgBz2OkbFAsTMHTdna-' + 'pZBRJa9lm5U', + 'q': '6R4dzo9LwHLO73EMQPQsmwXjVOvAS5W6rgQ-BCtMhec_QosAXIVE' + '3AGyfweqZm6rurXCVFykDLwJ30GepLQ8nTlzeV6clx0x70sa' + 'GGKKVmCsHuVYWwgIRyJTrt4SX29NQDZ_FE52NlO3OhPkj1Ex' + 'Sk_pGMqGRFd26K8g0jJsXXM', + 'dp': 'VByn-hs0qB2Ncmb8ZycUOgWu7ljmjz1up1ZKU_3ZzJWVDkej7-6H' + '7vcJ-u1OqgRxFv4v9_-aWPWl68VlWbkIkJbx6vniv6qrrXwB' + 'Zu4klOPwEYBOXsucrzXRYOjpJp5yNl2zRslFYQQC00bwpAxN' + 'CdfNLRZDlXhAqCUxlYqyt10', + 'dq': 'MJFbuGtWZvQEdRJicS3uFSY25LxxRc4eJJ8xpIC44rT5Ew4O' + 'tzf0zrlzzM92Cv1HvhCcOiNK8nRCwkbTnJEIh-EuU70I' + 'dttYSfilqSruk2x0r8Msk1qrDtbyBF60CToRKC2ycDKg' + 'olTyuaDnX4yU7lyTvdyD-L0YQwYpmmFy_k0', + 'qi': 'vy7XCwZ3jyMGik81TIZDAOQKC8FVUc0TG5KVYfti4tgw' + 'zUqFwtuB8Oc1ctCKRbE7uZUPwZh4OsCTLqIvqBQd' + 'a_kaxOxo5EF7iXj6yHmZ2s8P_Z_u3JLuh-oAT_6k' + 'mbLx6CAO0DbtKtxp24Ivc1hDfqSwWORgN1AOrSRC' + 'mE3nwxg' + }), + 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.' + 'fL5IL5cMCjjU9G9_ZjsD2XO0HIwTOwbVwulcZVw31_rx2qTcHzbYhIvrvbcVLTfJzn8xbQ3UEL442ZgZ1PcFYKENYePXiEyvYxPN8dmvj_' + 'OfLSJDEqR6kvwOb6nghGtxfzdB_VRvFt2eehbCA3gWpiOYHHvSTFdBPGx2KZHQisLz3oZR8EWiZ1woEpHy8a7FoQ2zzuDlZEJQOUrh09b_' + 'EJxmcE2jL6wmEtgabyxy3VgWg3GqSPUISlJZV9HThuVJezzktJdpntRDnAPUqjc8IwByGpMleIQcPuBUseRRPr_OsroOJ6eTl5DuFCmBOKb-eNNw5v-GEcVYr1w7X9oXoA.' + '0frdIwx8P8UAzh1s9_PgOA.' + 'RAzILH0xfs0yxzML1CzzGExCfE2_wzWKs0FVuXfM8R5H68yTqTbqIqRCp2feAH5GSvluzmztk2_CkGNSjAyoaw.' + '4nMUXOgmgWvM-08tIZ-h5w'); + }); + }); + + test( + 'Issue #26: Non-Obvious error happening while trying to create JWE token', + () async { + var jwk = + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + var builder = JsonWebEncryptionBuilder(); + builder.stringContent = + '{"aud": "somekey", "sub": 12, "iss": "auth.example.com", "exp": 1617349353}'; + builder.setProtectedHeader('createdAt', DateTime.now().toIso8601String()); + builder.addRecipient(jwk, algorithm: 'dir'); + builder.encryptionAlgorithm = 'A256GCM'; + + var jwe = builder.build(); + expect( + utf8.decode( + jwe.getPayloadFor(jwk, jwe.commonHeader, jwe.recipients.first)!), + '{"aud": "somekey", "sub": 12, "iss": "auth.example.com", "exp": 1617349353}'); + }); + + group('Fallback JWE JSON parsing (no recipients/header)', () { + test('Direct encryption without encrypted_key succeeds', () async { + var payload = 'Secret message'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A256GCM'; + // Generate a symmetric key for direct encryption + var key = JsonWebKey.generate(builder.encryptionAlgorithm); + builder.addRecipient(key, algorithm: 'dir'); + var jwe = builder.build(); + // Build JSON representation without recipients/header and intentionally omit encrypted_key + var compact = jwe.toCompactSerialization().split('.'); + // compact parts: [protected, encrypted_key (empty for dir), iv, ciphertext, tag] + var fallbackJson = { + 'protected': compact[0], + // intentionally no 'encrypted_key' + 'iv': compact[2], + 'ciphertext': compact[3], + 'tag': compact[4] + }; + var parsed = JsonWebEncryption.fromJson(fallbackJson); + var keyStore = JsonWebKeyStore()..addKey(key); + expect((await parsed.getPayload(keyStore)).stringContent, payload); + }); + + test('Missing encrypted_key for non-direct algorithm throws', () async { + var payload = 'Another secret'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A128CBC-HS256'; + // Use an octet key suitable for key wrapping (A128KW) + var key = + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + builder.addRecipient(key, algorithm: 'A128KW'); + var jwe = builder.build(); + var compact = jwe.toCompactSerialization().split('.'); + // Remove encrypted_key field + var fallbackJson = { + 'protected': compact[0], + // encrypted_key omitted but algorithm in protected header is not 'dir' + 'iv': compact[2], + 'ciphertext': compact[3], + 'tag': compact[4] + }; + expect( + () => JsonWebEncryption.fromJson(fallbackJson), throwsArgumentError); + }); + + test('Parse JWE protected header (Base64URL unpadded)', () { + final jweJson = { + 'ciphertext': + 'F9vgLFXCxXd46RVux-YNAT0aSxAevRvz0Po1vLrbc45vTC3r_S8fv2S24I9IVOjRvE1CJm73-cyf04CvtngCiyLwgzhnr2hFiIzVOQ4bM077_KqHkn0rWHD5xGekxNHcGpVMkcXfhwnRGNMTCfcnJV5YbolDNYXwYJw4Wcjk2suLCPB-NFK0yuakmszOzC82PIlKBJ4VQ0Gjh_R5DsQoz00IcpvQogzsYWxgbV87TsGXrjqbV8x97ng_8B0V0Yd9EURg3SWJqsuWFPFig9k2voNwurztmKoRiNbYEdIV5Zn-AP96G7p4i76rY0h_v-NdBsTz38z17Qu_1W8-RNsdcH0rGUblQPQV6VWVQjrS3D6AfSoz-uoYga2X3BISU1GTmsqtHnOauq03aT2pN33PqxQ0nxQgJ1bvU1E4BuHugN6Mt9kVwv9ssNeYk3QE6gax4LE24f1SGH3-PfKQ0hwGfeql6yRdVA', + 'encrypted_key': + 'eId1wPPb7TEH9PazhcUsYEk5_nPOfVmqxwui7W7k5bqbIvIsJKX7vIh3xrcYF51fheHvOdZfqewWoJY3PTtPwBg1pJ-IE6V8UPKFpyJ6p8ETjKpypE5VijXrYeqTfFAXxpU3dhk5xVnm7eF8YfymmqzHA2_ErFzqbHu6e5JK6xGFkSc_bMINBBs1612ow1HHBr1LnnGUGM5hVX0bydisnJG9kkxLX7PH_0Np1vzkRspVlsi16zK4uA09GXraxIVo5GQNPoEdoAqC7AK71DzThOBMZMbN7incVJxegBnO8G0oqkG9IvCHHglTP6wtOVF_qzNNZ36-q5uxDatw4-oc6Q', + 'iv': '4fX9lmzwwR1d4Jbw', + 'protected': + 'eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiZGlkOmlkZW4zOmJpbGxpb25zOnRlc3Q6MlZ4bm9pTnFkTVB5SE10VXdBRXpobldxWEdrRWVKcEFwNG50VGtMOFhUI2tleTEiLCJ0eXAiOiJhcHBsaWNhdGlvbi9pZGVuM2NvbW0tZW5jcnlwdGVkLWpzb24ifQ', + 'tag': 'of7WWH7gbhEiADgowsXiOA', + }; + // Parsing should succeed and derive header values from protected header (unpadded base64url) + final jwe = JsonWebEncryption.fromJson(jweJson); + final header = jwe.commonHeader; + expect(header.algorithm, 'RSA-OAEP-256'); + expect(header.encryptionAlgorithm, 'A256GCM'); + expect(header.type, 'application/iden3comm-encrypted-json'); + expect(header.keyId, isNotNull); + // Ensure keyId has expected suffix fragment + expect(header.keyId!.endsWith('#key1'), isTrue); + // Ensure no recipients array created (flattened/fallback single recipient) + expect(jwe.recipients.length, 1); + }); + + test('Key wrapping (non-dir) fallback with encrypted_key present', + () async { + var payload = 'Key wrap fallback'; + var builder = JsonWebEncryptionBuilder()..stringContent = payload; + builder.encryptionAlgorithm = 'A128CBC-HS256'; + var key = + JsonWebKey.fromJson({'kty': 'oct', 'k': 'GawgguFyGrWKav7AX4VKUg'}); + builder.addRecipient(key, algorithm: 'A128KW'); + var jwe = builder.build(); + var parts = jwe.toCompactSerialization().split('.'); + var fallbackJson = { + 'protected': parts[0], + 'encrypted_key': parts[1], + 'iv': parts[2], + 'ciphertext': parts[3], + 'tag': parts[4] + }; + var parsed = JsonWebEncryption.fromJson(fallbackJson); + var store = JsonWebKeyStore()..addKey(key); + expect((await parsed.getPayload(store)).stringContent, payload); + expect(parsed.commonHeader.algorithm, 'A128KW'); + expect(parsed.commonHeader.encryptionAlgorithm, 'A128CBC-HS256'); + }); + + test('Error when protected header missing', () { + var json = { + 'iv': 'AA', + 'ciphertext': 'AA', + 'tag': 'AA' + }; // intentionally minimal + expect(() => JsonWebEncryption.fromJson(json), throwsArgumentError); + }); + + test('Error for invalid protected header base64/json', () { + var json = { + 'protected': '***', // invalid base64 / json + 'iv': 'AA', + 'ciphertext': 'AA', + 'tag': 'AA' + }; + expect(() => JsonWebEncryption.fromJson(json), throwsA(isA())); + }); + }); +} + +void _doTests(dynamic payload, dynamic key, dynamic encoded) { + var jwe = encoded is String + ? JsonWebEncryption.fromCompactSerialization(encoded) + : JsonWebEncryption.fromJson(encoded); + var keys = key is JsonWebKeySet + ? key + : JsonWebKeySet.fromKeys(key == null ? [] : [key]); + var context = JsonWebKeyStore()..addKeySet(keys); + + Future expectPayload(JsonWebEncryption jwe) async { + var content = await jwe.getPayload(context); + if (payload is String) { + expect(content.stringContent, payload); + } else if (payload is Map) { + expect(content.jsonContent, payload); + } else if (payload is List) { + expect(content.data, payload); + } + } + + test('decode', () { + if (encoded is String) { + expect(jwe.toCompactSerialization(), encoded); + } else { + expect(jwe.toJson(), encoded); + } + }); + test('decrypt', () async { + await expectPayload(jwe); + }); + test('create', () async { + var builder = JsonWebEncryptionBuilder() + ..content = payload + ..encryptionAlgorithm = jwe.commonHeader.encryptionAlgorithm + ..additionalAuthenticatedData = jwe.additionalAuthenticatedData; + + var p = jwe.sharedProtectedHeader!.toJson(); + p.forEach((k, v) => builder.setProtectedHeader(k, v)); + builder.encryptionAlgorithm = jwe.commonHeader.encryptionAlgorithm; + if (keys.keys.isEmpty) { + builder.addRecipient(null, algorithm: 'none'); + } else { + for (var key in keys.keys) { + builder.addRecipient(key, algorithm: jwe.commonHeader.algorithm); + } + } + + jwe = builder.build(); + + if (encoded is String) jwe.toCompactSerialization(); + await expectPayload(jwe); + }); +} diff --git a/packages/jose_plus/test/jwk_error_test.dart b/packages/jose_plus/test/jwk_error_test.dart new file mode 100644 index 00000000..4208b9d2 --- /dev/null +++ b/packages/jose_plus/test/jwk_error_test.dart @@ -0,0 +1,328 @@ +import 'dart:convert'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:jose_plus/src/jwk.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonWebKey.fromCryptoKeys', () { + test('throws when both publicKey and privateKey are null', () { + expect( + () => JsonWebKey.fromCryptoKeys(), + throwsA(isA()), + ); + }); + + test('throws when an RSA private key is paired with a non-RSA public key', + () { + final rsaPriv = JsonWebKey.generate('RS256').cryptoKeyPair.privateKey + as RsaPrivateKey; + final ecPub = + JsonWebKey.generate('ES256').cryptoKeyPair.publicKey as EcPublicKey; + expect( + () => JsonWebKey.fromCryptoKeys(privateKey: rsaPriv, publicKey: ecPub), + throwsA(isA()), + ); + }); + + test('throws when an EC private key is paired with a non-EC public key', + () { + final ecPriv = + JsonWebKey.generate('ES256').cryptoKeyPair.privateKey as EcPrivateKey; + final rsaPub = + JsonWebKey.generate('RS256').cryptoKeyPair.publicKey as RsaPublicKey; + expect( + () => JsonWebKey.fromCryptoKeys(privateKey: ecPriv, publicKey: rsaPub), + throwsA(isA()), + ); + }); + + test('throws UnsupportedError for an unsupported public-only key type', () { + final okpPub = JsonWebKey.generate('EdDSA').cryptoKeyPair.publicKey; + expect( + () => JsonWebKey.fromCryptoKeys(publicKey: okpPub), + throwsA(isA()), + ); + }); + + test('throws UnsupportedError for an unsupported private key type', () { + final okpPriv = JsonWebKey.generate('EdDSA').cryptoKeyPair.privateKey; + expect( + () => JsonWebKey.fromCryptoKeys(privateKey: okpPriv), + throwsA(isA()), + ); + }); + + test('builds an RSA public JWK from a public key', () { + final rsaPub = + JsonWebKey.generate('RS256').cryptoKeyPair.publicKey as RsaPublicKey; + final jwk = JsonWebKey.fromCryptoKeys(publicKey: rsaPub, keyId: 'r1'); + expect(jwk.keyType, 'RSA'); + expect(jwk.keyId, 'r1'); + expect(jwk.cryptoKeyPair.privateKey, isNull); + }); + + test('builds an EC public JWK from a public key', () { + final ecPub = + JsonWebKey.generate('ES256').cryptoKeyPair.publicKey as EcPublicKey; + final jwk = JsonWebKey.fromCryptoKeys(publicKey: ecPub, keyId: 'e1'); + expect(jwk.keyType, 'EC'); + expect(jwk.keyId, 'e1'); + }); + }); + + group('JsonWebKey factory constructors', () { + test('rsa honours keyId and algorithm', () { + final jwk = JsonWebKey.rsa( + modulus: BigInt.parse('12345678'), + exponent: BigInt.parse('65537'), + keyId: 'rsa-key', + algorithm: 'RS256', + ); + expect(jwk.keyType, 'RSA'); + expect(jwk.keyId, 'rsa-key'); + expect(jwk.algorithm, 'RS256'); + }); + + test('ec honours keyId and algorithm', () { + final source = JsonWebKey.generate('ES256'); + final pub = source.cryptoKeyPair.publicKey as EcPublicKey; + final jwk = JsonWebKey.ec( + curve: 'P-256', + xCoordinate: pub.xCoordinate, + yCoordinate: pub.yCoordinate, + keyId: 'ec-key', + algorithm: 'ES256', + ); + expect(jwk.keyType, 'EC'); + expect(jwk.keyId, 'ec-key'); + expect(jwk.algorithm, 'ES256'); + }); + + test('symmetric builds an oct key with a keyId', () { + final jwk = JsonWebKey.symmetric( + key: BigInt.parse('123456789abcdef0', radix: 16), + keyId: 'sym-key', + ); + expect(jwk.keyType, 'oct'); + expect(jwk.keyId, 'sym-key'); + }); + }); + + group('JsonWebKey X.509 accessors', () { + test('exposes x5u, x5t and x5t#S256 parameters', () { + final jwk = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'GawgguFyGrWKav7AX4VKUg', + 'x5u': 'https://example.com/cert', + 'x5t': 'thumb-1', + 'x5t#S256': 'thumb-256', + })!; + expect(jwk.x509Url, Uri.parse('https://example.com/cert')); + expect(jwk.x509CertificateThumbprint, 'thumb-1'); + expect(jwk.x509CertificateSha256Thumbprint, 'thumb-256'); + }); + + test('throws a FormatException when x5c is not a valid certificate', () { + // base64 of DER [0x02, 0x01, 0x05] — an ASN.1 INTEGER, not a SEQUENCE. + final badCert = base64.encode([2, 1, 5]); + expect( + () => JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'GawgguFyGrWKav7AX4VKUg', + 'x5c': [badCert], + }), + throwsA(isA()), + ); + }); + }); + + group('JsonWebKey.fromPem with an unsupported PEM block type', () { + test('throws UnsupportedError for a PKCS#10 certificate request', () { + // `x509.parsePem` decodes a "CERTIFICATE REQUEST" block into a + // `CertificationRequest`, which `fromPem` has no conversion for (only + // `PrivateKeyInfo`, `KeyPair`, `X509Certificate` and + // `SubjectPublicKeyInfo` are supported). + const csrPem = '-----BEGIN CERTIFICATE REQUEST-----\n' + 'MIICxDCCAawCAQAwfzELMAkGA1UEBhMCQkUxEzARBgNVBAgTClNvbWUtU3RhdGUx\n' + 'DjAMBgNVBAcTBUdoZW50MQ8wDQYDVQQKEwZBcHBzVXAxFDASBgNVBAMTC1JpayBC\n' + 'ZWxsZW5zMSQwIgYJKoZIhvcNAQkBFhVyaWsuYmVsbGVuc0BhcHBzdXAuYmUwggEi\n' + 'MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIEQi/Jsu5D1d6G4tUBGFBfzBr\n' + 'Om72d0nPo2nitdw4mjnqdSn08xPeu8ILIUds3Mbti4AXyk/Ar+RsnxtG8j85RvH+\n' + 'sx8nIc41J4//9cIlYjVCScjAvuklz2JWIMCT50y2rJ1pf0NFrs/pghRQBX5yAP0z\n' + 'KTWjQsgojLp1V4UVLfyWKdBQRDA6W2dybc8WtvCc4n3cLrMDYRY3Kwl7zl5TmIr9\n' + 'cw+B50W/9Q+lv4x3Yzj7zxs2CHrjhY1x14es1hTf6VlNH07Bb9MM8HtONq73616S\n' + 'ojzo3Wd16Zps2Kl7COHu5pG2WSR95ddjpaJur9pbZLft8n2nLLMNd+c4u2YzAgMB\n' + 'AAGgADANBgkqhkiG9w0BAQsFAAOCAQEAJyv7nIC2g//naaXwMAiBML6JlSQcrPIb\n' + 'GoVvgAXdcT9GLTg7h4So2XRj3R/qq5yzSRjZ6tSjEHpufG2z/6ewZNb/kynQjdKp\n' + 'wQ8sumReuGgOcnHw6ggEadRxVBRjCvLI2Vdz1K8aVQc0bpeEJydop+aMjLaYNypo\n' + 'dQxDBcoDQqpn5ocxQCVLXUVtuWdJQLPwaN+EuSoeuqoFgsx2MCKjCpdOfRBONqQZ\n' + '3661iihx1kYj3BT3pUDuf8Ztbpf4th0iRX0HaQ86Cv23csvlGEggo332tuHE8nqt\n' + 'QRHW6F76DyflCMF+rSGKlf6PvU9bDjNccjxiYMum4HG+hoUqsnTW2g==\n' + '-----END CERTIFICATE REQUEST-----\n'; + expect( + () => JsonWebKey.fromPem(csrPem), + throwsA(isA()), + ); + }); + }); + + group('JsonWebKey._getAlgorithm', () { + test( + 'sign with algorithm "none" throws UnsupportedError ' + '(AlgorithmIdentifier.getByJwaName returns null only for "none")', () { + final key = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + })!; + expect( + () => key.sign([1, 2, 3], algorithm: 'none'), + throwsA(isA()), + ); + }); + }); + + group('JsonWebKey cryptographic operation guards', () { + test('sign throws StateError when the key cannot sign', () { + final publicOnly = JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + })!; + expect( + () => publicOnly.sign([1, 2, 3], algorithm: 'RS256'), + throwsStateError, + ); + }); + + test('sign throws ArgumentError when algorithm differs from key algorithm', + () { + final key = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + 'alg': 'HS256', + })!; + expect( + () => key.sign([1, 2, 3], algorithm: 'HS384'), + throwsA(isA()), + ); + }); + + test('sign throws ArgumentError when no algorithm can be determined', () { + final key = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + })!; + expect(() => key.sign([1, 2, 3]), throwsA(isA())); + }); + + test('wrapKey throws UnsupportedError when wrapping a non-oct key', () { + final wrappingKey = JsonWebKey.generate('RSA-OAEP'); + final ecKey = JsonWebKey.generate('ES256'); + expect( + () => wrappingKey.wrapKey(ecKey), + throwsA(isA()), + ); + }); + + test('wrapKey/unwrapKey round-trips a symmetric content key', () { + final wrappingKey = JsonWebKey.generate('RSA-OAEP'); + final cek = JsonWebKey.generate('A128GCM'); + final wrapped = wrappingKey.wrapKey(cek); + final unwrapped = wrappingKey.unwrapKey(wrapped); + expect(unwrapped.keyType, 'oct'); + expect(unwrapped['k'], cek['k']); + }); + }); + + group('JsonWebKey.usableForOperation and algorithmForOperation', () { + test('a private RSA signing key can sign but a public one cannot', () { + final pub = + JsonWebKey.generate('RS256').cryptoKeyPair.publicKey as RsaPublicKey; + final publicJwk = JsonWebKey.fromCryptoKeys(publicKey: pub); + expect(publicJwk.usableForOperation('sign'), isFalse); + expect(publicJwk.usableForOperation('verify'), isTrue); + expect(publicJwk.algorithmForOperation('sign'), isNull); + }); + }); + + group('JsonWebKeySetLoader', () { + test('global getter and setter can be swapped and restored', () async { + final original = JsonWebKeySetLoader.current; + final custom = DefaultJsonWebKeySetLoader(); + try { + JsonWebKeySetLoader.global = custom; + expect(JsonWebKeySetLoader.current, same(custom)); + } finally { + JsonWebKeySetLoader.global = original; + } + }); + }); + + group('DefaultJsonWebKeySetLoader.readAsString', () { + test('reads a JWK set from a data: URI', () async { + final setJson = json.encode({ + 'keys': [ + {'kty': 'oct', 'alg': 'A128KW', 'k': 'GawgguFyGrWKav7AX4VKUg'} + ] + }); + final uri = Uri.parse( + 'data:application/json;charset=utf-8,${Uri.encodeComponent(setJson)}'); + final loader = DefaultJsonWebKeySetLoader(); + final set = await loader.read(uri); + expect(set.keys, hasLength(1)); + expect(set.keys.single.algorithm, 'A128KW'); + }); + + test('throws UnsupportedError for an unsupported URI scheme', () { + final loader = DefaultJsonWebKeySetLoader(); + expect( + () => loader.readAsString(Uri.parse('ftp://example.com/keys')), + throwsA(isA()), + ); + }); + + test('honours date/expires headers to compute cache expiry', () async { + final setJson = json.encode({'keys': []}); + final now = DateTime.utc(2022, 1, 1, 12); + var callCount = 0; + final client = MockClient((request) async { + callCount++; + return Response(setJson, 200, headers: { + 'date': formatHttpDate(now), + 'expires': formatHttpDate(now.add(const Duration(hours: 1))), + }); + }); + final loader = DefaultJsonWebKeySetLoader(httpClient: client); + final uri = Uri.parse('https://example.com/keys.json'); + await loader.readAsString(uri); + // Second read within the cache window should not hit the network again. + await loader.readAsString(uri); + expect(callCount, 1); + }); + + test('falls back to the default cache expiry when date header is invalid', + () async { + final setJson = json.encode({'keys': []}); + final client = MockClient((request) async { + return Response(setJson, 200, headers: {'date': 'not-a-real-date'}); + }); + final loader = DefaultJsonWebKeySetLoader(httpClient: client); + final body = + await loader.readAsString(Uri.parse('https://example.com/k.json')); + expect(body, setJson); + }); + }); +} diff --git a/packages/jose_plus/test/jwk_test.dart b/packages/jose_plus/test/jwk_test.dart new file mode 100644 index 00000000..d7c2d0e6 --- /dev/null +++ b/packages/jose_plus/test/jwk_test.dart @@ -0,0 +1,351 @@ +@Tags(['slow-web-crypto']) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto_keys_plus/crypto_keys.dart'; +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWK Examples from RFC7517', () { + group('Example JSON Web Key Sets', () { + test('Example Public Keys', () { + var json = { + 'keys': [ + { + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4', + 'y': '4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', + 'use': 'enc', + 'kid': '1' + }, + { + 'kty': 'RSA', + 'n': '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx' + '4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs' + 'tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2' + 'QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI' + 'SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb' + 'w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', + 'e': 'AQAB', + 'alg': 'RS256', + 'kid': '2011-04-29' + } + ] + }; + + var key1 = JsonWebKey.fromJson(json['keys']![0])!; + + expect(key1.keyType, 'EC'); + expect(key1.keyId, '1'); + expect(key1.publicKeyUse, 'enc'); + + var key2 = JsonWebKey.fromJson(json['keys']![1])!; + + expect(key2.keyType, 'RSA'); + expect(key2.keyId, '2011-04-29'); + expect(key2.algorithm, 'RS256'); + + var keySet = JsonWebKeySet.fromJson(json); + + expect(keySet.keys[0], key1); + expect(keySet.keys[1], key2); + + expect(keySet.toJson(), json); + }); + + test('Example Private Keys', () { + var json = { + 'keys': [ + { + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4', + 'y': '4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', + 'd': '870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE', + 'use': 'enc', + 'kid': '1' + }, + { + 'kty': 'RSA', + 'n': '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4' + 'cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMst' + 'n64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2Q' + 'vzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbIS' + 'D08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw' + '0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', + 'e': 'AQAB', + 'd': 'X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9' + 'M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqij' + 'wp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d' + '_cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBz' + 'nbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFz' + 'me1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q', + 'p': '83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPV' + 'nwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqV' + 'WlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs', + 'q': '3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyum' + 'qjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgx' + 'kIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk', + 'dp': 'G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oim' + 'YwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_Nmtu' + 'YZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0', + 'dq': 's9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUU' + 'vMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9' + 'GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk', + 'qi': 'GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzg' + 'UIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rx' + 'yR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU', + 'alg': 'RS256', + 'kid': '2011-04-29' + } + ] + }; + + var key1 = JsonWebKey.fromJson(json['keys']![0])!; + + expect(key1.keyType, 'EC'); + expect(key1.keyId, '1'); + expect(key1.publicKeyUse, 'enc'); + + var key2 = JsonWebKey.fromJson(json['keys']![1])!; + + expect(key2.keyType, 'RSA'); + expect(key2.keyId, '2011-04-29'); + expect(key2.algorithm, 'RS256'); + + var keySet = JsonWebKeySet.fromJson(json); + + expect(keySet.keys[0], key1); + expect(keySet.keys[1], key2); + + expect(keySet.toJson(), json); + }); + + test('Example Symmetric Keys', () { + var json = { + 'keys': [ + {'kty': 'oct', 'alg': 'A128KW', 'k': 'GawgguFyGrWKav7AX4VKUg'}, + { + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + 'kid': 'HMAC key used in JWS spec Appendix A.1 example' + } + ] + }; + + var key1 = JsonWebKey.fromJson(json['keys']![0])!; + + expect(key1.keyType, 'oct'); + expect(key1.algorithm, 'A128KW'); + + var key2 = JsonWebKey.fromJson(json['keys']![1])!; + + expect(key2.keyType, 'oct'); + expect(key2.keyId, 'HMAC key used in JWS spec Appendix A.1 example'); + + var keySet = JsonWebKeySet.fromJson(json); + + expect(keySet.keys[0], key1); + expect(keySet.keys[1], key2); + + expect(keySet.toJson(), json); + }); + + test('Web Key Set from url', () async { + var v = { + 'keys': [ + { + 'kty': 'oct', + 'alg': 'A128KW', + 'k': 'GawgguFyGrWKav7AX4VKUg', + 'kid': 'key1' + }, + { + 'kty': 'oct', + 'k': 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75' + 'aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + 'kid': 'HMAC key used in JWS spec Appendix A.1 example' + } + ] + }; + + var client = MockClient((request) async { + return Response(json.encode(v), 200); + }); + + var store = JsonWebKeyStore() + ..addKeySetUrl(Uri.parse('https://appsup.be/keys.json')); + + await JsonWebKeySetLoader.runZoned(() async { + var key = await store + .findJsonWebKeys( + JoseHeader.fromJson({'kid': 'key1', 'alg': 'A128KW'}), 'sign') + .first; + + expect(key?.keyType, 'oct'); + expect(key?.algorithm, 'A128KW'); + + key = await store + .findJsonWebKeys( + JoseHeader.fromJson({'kid': 'key1', 'alg': 'A128KW'}), 'sign') + .first; + }, loader: DefaultJsonWebKeySetLoader(httpClient: client)); + }); + }); + + test("Example Use of 'x5c' (X.509 Certificate Chain) Parameter", () { + var json = { + 'kty': 'RSA', + 'use': 'sig', + 'kid': '1b94c', + 'n': 'vrjOfz9Ccdgx5nQudyhdoR17V-IubWMeOZCwX_jj0hgAsz2J_pqYW08' + 'PLbK_PdiVGKPrqzmDIsLI7sA25VEnHU1uCLNwBuUiCO11_-7dYbsr4iJmG0Q' + 'u2j8DsVyT1azpJC_NG84Ty5KKthuCaPod7iI7w0LK9orSMhBEwwZDCxTWq4a' + 'YWAchc8t-emd9qOvWtVMDC2BXksRngh6X5bUYLy6AyHKvj-nUy1wgzjYQDwH' + 'MTplCoLtU-o-8SNnZ1tmRoGE9uJkBLdh5gFENabWnU5m1ZqZPdwS-qo-meMv' + 'VfJb6jJVWRpl2SUtCnYG2C32qvbWbjZ_jBPD5eunqsIo1vQ', + 'e': 'AQAB', + 'x5c': [ + 'MIIDQjCCAiqgAwIBAgIGATz/FuLiMA0GCSqGSIb3DQEBBQUAMGIxCzAJB' + 'gNVBAYTAlVTMQswCQYDVQQIEwJDTzEPMA0GA1UEBxMGRGVudmVyMRwwGgYD' + 'VQQKExNQaW5nIElkZW50aXR5IENvcnAuMRcwFQYDVQQDEw5CcmlhbiBDYW1' + 'wYmVsbDAeFw0xMzAyMjEyMzI5MTVaFw0xODA4MTQyMjI5MTVaMGIxCzAJBg' + 'NVBAYTAlVTMQswCQYDVQQIEwJDTzEPMA0GA1UEBxMGRGVudmVyMRwwGgYDV' + 'QQKExNQaW5nIElkZW50aXR5IENvcnAuMRcwFQYDVQQDEw5CcmlhbiBDYW1w' + 'YmVsbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL64zn8/QnH' + 'YMeZ0LncoXaEde1fiLm1jHjmQsF/449IYALM9if6amFtPDy2yvz3YlRij66' + 's5gyLCyO7ANuVRJx1NbgizcAblIgjtdf/u3WG7K+IiZhtELto/A7Fck9Ws6' + 'SQvzRvOE8uSirYbgmj6He4iO8NCyvaK0jIQRMMGQwsU1quGmFgHIXPLfnpn' + 'fajr1rVTAwtgV5LEZ4Iel+W1GC8ugMhyr4/p1MtcIM42EA8BzE6ZQqC7VPq' + 'PvEjZ2dbZkaBhPbiZAS3YeYBRDWm1p1OZtWamT3cEvqqPpnjL1XyW+oyVVk' + 'aZdklLQp2Btgt9qr21m42f4wTw+Xrp6rCKNb0CAwEAATANBgkqhkiG9w0BA' + 'QUFAAOCAQEAh8zGlfSlcI0o3rYDPBB07aXNswb4ECNIKG0CETTUxmXl9KUL' + '+9gGlqCz5iWLOgWsnrcKcY0vXPG9J1r9AqBNTqNgHq2G03X09266X5CpOe1' + 'zFo+Owb1zxtp3PehFdfQJ610CDLEaS9V9Rqp17hCyybEpOGVwe8fnk+fbEL' + '2Bo3UPGrpsHzUoaGpDftmWssZkhpBJKVMJyf/RuP2SmmaIzmnw9JiSlYhzo' + '4tpzd5rFXhjRbg4zW9C+2qok+2+qDM1iJ684gPHMIY8aLWrdgQTxkumGmTq' + 'gawR+N5MDtdPTEQ0XfIBc2cJEUyMTY5MPvACWpkA6SdS4xSvdXK3IVfOWA==' + ] + }; + + var key = JsonWebKey.fromJson(json)!; + + expect(key.keyType, 'RSA'); + + expect(key.toJson(), json); + + json['e'] = base64.encode([0, 0, 0]); + expect(() => JsonWebKey.fromJson(json), + throwsA(TypeMatcher())); + }); + }); + + group('JWK from pem files', () { + test('JWK from pem PRIVATE RSA KEY', () { + var key = JsonWebKey.fromPem( + File('test/pem/rsa.key').readAsStringSync(), + ); + + expect(key.keyType, 'RSA'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect(key.cryptoKeyPair.privateKey, isA()); + }); + test('JWK from pem PUBLIC KEY with RSA', () { + var key = JsonWebKey.fromPem( + File('test/pem/rsa.pub.key').readAsStringSync(), + ); + + expect(key.keyType, 'RSA'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect(key.cryptoKeyPair.privateKey, isNull); + }); + test('JWK from pem PRIVATE EC KEY with P-256 curve', () { + var key = JsonWebKey.fromPem( + File('test/pem/ec256.key').readAsStringSync(), + ); + + expect(key.keyType, 'EC'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect((key.cryptoKeyPair.publicKey as EcKey).curve, curves.p256); + expect(key.cryptoKeyPair.privateKey, isA()); + expect((key.cryptoKeyPair.privateKey as EcKey).curve, curves.p256); + }); + test('JWK from pem PUBLIC KEY with EC P-256 curve', () { + var key = JsonWebKey.fromPem( + File('test/pem/ec256.pub.key').readAsStringSync(), + ); + + expect(key.keyType, 'EC'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect((key.cryptoKeyPair.publicKey as EcKey).curve, curves.p256); + expect(key.cryptoKeyPair.privateKey, isNull); + }); + test('JWK from pem PRIVATE EC KEY with P-256K curve', () { + var key = JsonWebKey.fromPem( + File('test/pem/ec256k.key').readAsStringSync(), + ); + + expect(key.keyType, 'EC'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect((key.cryptoKeyPair.publicKey as EcKey).curve, curves.p256k); + expect(key.cryptoKeyPair.privateKey, isA()); + expect((key.cryptoKeyPair.privateKey as EcKey).curve, curves.p256k); + }); + test('JWK from pem PUBLIC KEY with EC P-256K curve', () { + var key = JsonWebKey.fromPem( + File('test/pem/ec256k.pub.key').readAsStringSync(), + ); + + expect(key.keyType, 'EC'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect((key.cryptoKeyPair.publicKey as EcKey).curve, curves.p256k); + expect(key.cryptoKeyPair.privateKey, isNull); + }); + test('JWK from pem CERTIFICATE', () { + var key = JsonWebKey.fromPem('-----BEGIN CERTIFICATE-----\n' + 'MIIDHDCCAgSgAwIBAgIIcYRws2sTxJkwDQYJKoZIhvcNAQEFBQAwMTEvMC0GA1UE\n' + 'AxMmc2VjdXJldG9rZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wHhcNMjEw\n' + 'NDA2MDkyMDIwWhcNMjEwNDIyMjEzNTIwWjAxMS8wLQYDVQQDEyZzZWN1cmV0b2tl\n' + 'bi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD\n' + 'ggEPADCCAQoCggEBAMoRTVdYXX6kW8oEplmvw5K2LnN3TSxdU2E4r3LKwY5wWEOI\n' + 'EJkgXq5mj+1D/AESJRE8eveVAKlR5/vBITPuJT99agjG/4vr9CNdEZjPc/TmqFmX\n' + 'wldeX/oE89LIoSuBKR/g3CRI17Z/0V/ZaeLwNlWz/A/L6+MEfEbgAIiSxXFkctXL\n' + 'TIWf3Ith24OTN8hVCgCaUWVLuY+FGprUnqQOqn1lpbtb1fgTSI/JAGXu6wsESyc3\n' + 'xslD2e4VyBQ1i+JoW3/VKydlODd3THydFRBHGPdJQkLH4ccDh2kQ4sWQ4vjupSsk\n' + 'BKMAvLqftpvVUo6LogEXNRmmI6sjluRlEvYk14kCAwEAAaM4MDYwDAYDVR0TAQH/\n' + 'BAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwDQYJ\n' + 'KoZIhvcNAQEFBQADggEBADFwIJVQERKO+x8Fx01ySjgSG6Rb81a17WQSCP2dlYmK\n' + 'FBvwaKK5tGVDt3RUnMgM5myEY11TX8yBF8UstxkqtMTzJh+K1hV6lC11YRqWzodq\n' + 'mJUBDuU39MYcRgoQn7szodBckdUGQlkTZti7xLApewkDpmR3Wx0KQBQpGt20Oaoq\n' + 'B2a5DVq4KsRirPtS71QvekM9Aars7pKrVNhxvXgkIMpiUAj3GJR5NAsJD0tsa9LM\n' + 'Lvo31/AE1VKiRJ9ta21m15wO4CJyAiWvRbRiHDN9b9oXuJwUlzUgT0GFWHayt56e\n' + 'CYTU00dPphNMO1O07aqHq2O44/wPXYtQGDlHsg4sCeM=\n' + '-----END CERTIFICATE-----\n'); + + expect(key.keyType, 'RSA'); + expect(key.cryptoKeyPair.publicKey, isA()); + expect(key.cryptoKeyPair.privateKey, isNull); + }); + }, testOn: 'vm'); + + group('Issues', () { + test( + 'Issue #16: _intToBase64 bug causing decryption fail when create key using exponent=65537', + () { + final jwk = JsonWebKey.rsa( + modulus: BigInt.parse('12345678'), + exponent: BigInt.parse('65537'), + ); + var publicKey = jwk.cryptoKeyPair.publicKey as RsaPublicKey; + expect(publicKey.modulus, BigInt.parse('12345678')); + expect(publicKey.exponent, BigInt.parse('65537')); + }); + }); +} diff --git a/packages/jose_plus/test/jws_error_test.dart b/packages/jose_plus/test/jws_error_test.dart new file mode 100644 index 00000000..a85b7f7b --- /dev/null +++ b/packages/jose_plus/test/jws_error_test.dart @@ -0,0 +1,98 @@ +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + final octKey = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + })!; + + group('JsonWebSignature.fromCompactSerialization', () { + test('throws when the serialization does not have three parts', () { + expect( + () => JsonWebSignature.fromCompactSerialization('only.two'), + throwsA(isA()), + ); + expect( + () => JsonWebSignature.fromCompactSerialization('a.b.c.d'), + throwsA(isA()), + ); + }); + }); + + group('JsonWebSignature.toCompactSerialization', () { + test('throws for a JWS with multiple signatures', () { + final jws = JsonWebSignature.fromJson({ + 'payload': 'eyJpc3MiOiJqb2UifQ', + 'signatures': [ + {'protected': 'eyJhbGciOiJSUzI1NiJ9', 'signature': 'AAAA'}, + {'protected': 'eyJhbGciOiJFUzI1NiJ9', 'signature': 'BBBB'}, + ], + }); + expect(() => jws.toCompactSerialization(), throwsStateError); + }); + + test('throws when a recipient has an unprotected header', () { + final jws = JsonWebSignature.fromJson({ + 'payload': 'eyJpc3MiOiJqb2UifQ', + 'protected': 'eyJhbGciOiJFUzI1NiJ9', + 'header': {'kid': 'key-1'}, + 'signature': 'AAAA', + }); + expect(() => jws.toCompactSerialization(), throwsStateError); + }); + }); + + group('JsonWebSignatureBuilder.build', () { + test('throws when there are no recipients', () { + final builder = JsonWebSignatureBuilder()..content = 'hello'; + expect(() => builder.build(), throwsStateError); + }); + + test('throws when no payload has been set', () { + final builder = JsonWebSignatureBuilder() + ..addRecipient(null, algorithm: 'none'); + expect(() => builder.build(), throwsStateError); + }); + + test( + 'throws when a protected header alg conflicts with the recipient ' + 'algorithm', () { + final builder = JsonWebSignatureBuilder() + ..content = 'hello' + ..setProtectedHeader('alg', 'HS512') + ..addRecipient(octKey, algorithm: 'HS256'); + expect(() => builder.build(), throwsA(isA())); + }); + + test( + 'succeeds and produces a verifiable JWS when protected header alg ' + 'matches the recipient algorithm', () async { + final builder = JsonWebSignatureBuilder() + ..content = 'hello' + ..setProtectedHeader('alg', 'HS256') + ..addRecipient(octKey, algorithm: 'HS256'); + final jws = builder.build(); + + final keyStore = JsonWebKeyStore()..addKey(octKey); + final payload = await jws.getPayload(keyStore); + expect(payload.stringContent, 'hello'); + }); + }); + + group('JsonWebSignature.getPayloadFor with alg none', () { + test('returns the payload only when no key and empty signature', () async { + final builder = JsonWebSignatureBuilder() + ..content = 'unsecured' + ..addRecipient(null, algorithm: 'none'); + final jws = JsonWebSignature.fromCompactSerialization( + builder.build().toCompactSerialization()); + + final keyStore = JsonWebKeyStore(); + final payload = + await jws.getPayload(keyStore, allowedAlgorithms: ['none']); + expect(payload.stringContent, 'unsecured'); + }); + }); +} diff --git a/packages/jose_plus/test/jws_test.dart b/packages/jose_plus/test/jws_test.dart new file mode 100644 index 00000000..dc203c7c --- /dev/null +++ b/packages/jose_plus/test/jws_test.dart @@ -0,0 +1,351 @@ +@Tags(['slow-web-crypto']) +library; + +import 'dart:convert'; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWS Examples from RFC7515', () { + group('Example JWS Using HMAC SHA-256', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + }), + 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + }); + group('Example JWS Using RSASSA-PKCS1-v1_5 SHA-256', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': 'ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddx' + 'HmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMs' + 'D1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSH' + 'SXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdV' + 'MTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8' + 'NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ', + 'e': 'AQAB', + 'd': 'Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97I' + 'jlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0' + 'BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn' + '439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYT' + 'CBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLh' + 'BOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ', + 'p': '4BzEEOtIpmVdVEZNCqS7baC4crd0pqnRH_5IB3jw3bcxGn6QLvnEtfdUdi' + 'YrqBdss1l58BQ3KhooKeQTa9AB0Hw_Py5PJdTJNPY8cQn7ouZ2KKDcmnPG' + 'BY5t7yLc1QlQ5xHdwW1VhvKn-nXqhJTBgIPgtldC-KDV5z-y2XDwGUc', + 'q': 'uQPEfgmVtjL0Uyyx88GZFF1fOunH3-7cepKmtH4pxhtCoHqpWmT8YAmZxa' + 'ewHgHAjLYsp1ZSe7zFYHj7C6ul7TjeLQeZD_YwD66t62wDmpe_HlB-TnBA' + '-njbglfIsRLtXlnDzQkv5dTltRJ11BKBBypeeF6689rjcJIDEz9RWdc', + 'dp': 'BwKfV3Akq5_MFZDFZCnW-wzl-CCo83WoZvnLQwCTeDv8uzluRSnm71I3Q' + 'CLdhrqE2e9YkxvuxdBfpT_PI7Yz-FOKnu1R6HsJeDCjn12Sk3vmAktV2zb' + '34MCdy7cpdTh_YVr7tss2u6vneTwrA86rZtu5Mbr1C1XsmvkxHQAdYo0', + 'dq': 'h_96-mK1R_7glhsum81dZxjTnYynPbZpHziZjeeHcXYsXaaMwkOlODsWa' + '7I9xXDoRwbKgB719rrmI2oKr6N3Do9U0ajaHF-NKJnwgjMd2w9cjz3_-ky' + 'NlxAr2v4IKhGNpmM5iIgOS1VZnOZ68m6_pbLBSp3nssTdlqvd0tIiTHU', + 'qi': 'IYd7DHOhrWvxkwPQsRM2tOgrjbcrfvtQJipd-DlcxyVuuM9sQLdgjVk2o' + 'y26F0EmpScGLq2MowX7fhd_QJQ3ydy5cY7YIBi87w93IKLEdfnbJtoOPLU' + 'W0ITrJReOgo1cq9SbsxYawBgfp_gh6A5603k2-ZQwVK0JKSHuLFkuQ3U' + }), + 'eyJhbGciOiJSUzI1NiJ9.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.' + 'cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZmh7' + 'AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjbKBYNX4' + 'BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHlb1L07Qe7K' + '0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZESc6BfI7noOPqv' + 'hJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AXLIhWkWywlVmtVrB' + 'p0igcN_IoypGlUPQGe77Rw'); + }); + group('Example JWS Using ECDSA P-256 SHA-256', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + JsonWebKey.fromJson({ + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y': 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + 'd': 'jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI' + }), + 'eyJhbGciOiJFUzI1NiJ9.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.' + 'DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSA' + 'pmWQxfKTUJqPP3-Kg6NU1Q'); + }); + group('Example JWS Using ECDSA P-521 SHA-512', () { + _doTests( + 'Payload', + JsonWebKey.fromJson({ + 'kty': 'EC', + 'crv': 'P-521', + 'x': 'AekpBQ8ST8a8VcfVOTNl353vSrDCLLJXmPk06wTjxrrjcBpXp5EOnYG_' + 'NjFZ6OvLFV1jSfS9tsz4qUxcWceqwQGk', + 'y': 'ADSmRA43Z1DSNx_RvcLI87cdL07l6jQyyBXMoxVg_l2Th-x3S1WDhjDl' + 'y79ajL4Kkd0AZMaZmh9ubmf63e3kyMj2', + 'd': 'AY5pb7A0UFiB3RELSD64fTLOSV_jazdF7fLYyuTw8lOfRhWg6Y6rUrPA' + 'xerEzgdRhajnu0ferB0d53vM9mE15j2C' + }), + 'eyJhbGciOiJFUzUxMiJ9.' + 'UGF5bG9hZA.' + 'AdwMgeerwtHoh-l192l60hp9wAHZFVJbLfD_UxMi70cwnZOYaRI1bKPWROc-mZZq' + 'wqT2SI-KGDKB34XO0aw_7XdtAG8GaSwFKdCAPZgoXD2YBJZCPEX3xKpRwcdOO8Kp' + 'EHwJjyqOgzDO7iKvU8vcnwNrmxYbSW9ERBXukOXolLzeO_Jn'); + }); + group('Example Unsecured JWS', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + null, + 'eyJhbGciOiJub25lIn0.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.', + allowedAlgorithms: ['none']); + }); + group('Example JWS Using General JWS JSON Serialization', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + JsonWebKeySet.fromKeys([ + JsonWebKey.fromJson({ + 'kty': 'RSA', + 'kid': '2010-12-29', + 'n': 'ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddx' + 'HmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMs' + 'D1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSH' + 'SXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdV' + 'MTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8' + 'NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ', + 'e': 'AQAB', + 'd': 'Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97I' + 'jlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0' + 'BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn' + '439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYT' + 'CBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLh' + 'BOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ', + 'p': '4BzEEOtIpmVdVEZNCqS7baC4crd0pqnRH_5IB3jw3bcxGn6QLvnEtfdUdi' + 'YrqBdss1l58BQ3KhooKeQTa9AB0Hw_Py5PJdTJNPY8cQn7ouZ2KKDcmnPG' + 'BY5t7yLc1QlQ5xHdwW1VhvKn-nXqhJTBgIPgtldC-KDV5z-y2XDwGUc', + 'q': 'uQPEfgmVtjL0Uyyx88GZFF1fOunH3-7cepKmtH4pxhtCoHqpWmT8YAmZxa' + 'ewHgHAjLYsp1ZSe7zFYHj7C6ul7TjeLQeZD_YwD66t62wDmpe_HlB-TnBA' + '-njbglfIsRLtXlnDzQkv5dTltRJ11BKBBypeeF6689rjcJIDEz9RWdc', + 'dp': 'BwKfV3Akq5_MFZDFZCnW-wzl-CCo83WoZvnLQwCTeDv8uzluRSnm71I3Q' + 'CLdhrqE2e9YkxvuxdBfpT_PI7Yz-FOKnu1R6HsJeDCjn12Sk3vmAktV2zb' + '34MCdy7cpdTh_YVr7tss2u6vneTwrA86rZtu5Mbr1C1XsmvkxHQAdYo0', + 'dq': 'h_96-mK1R_7glhsum81dZxjTnYynPbZpHziZjeeHcXYsXaaMwkOlODsWa' + '7I9xXDoRwbKgB719rrmI2oKr6N3Do9U0ajaHF-NKJnwgjMd2w9cjz3_-ky' + 'NlxAr2v4IKhGNpmM5iIgOS1VZnOZ68m6_pbLBSp3nssTdlqvd0tIiTHU', + 'qi': 'IYd7DHOhrWvxkwPQsRM2tOgrjbcrfvtQJipd-DlcxyVuuM9sQLdgjVk2o' + 'y26F0EmpScGLq2MowX7fhd_QJQ3ydy5cY7YIBi87w93IKLEdfnbJtoOPLU' + 'W0ITrJReOgo1cq9SbsxYawBgfp_gh6A5603k2-ZQwVK0JKSHuLFkuQ3U' + }), + JsonWebKey.fromJson({ + 'kid': 'e9bc097a-ce51-4036-9562-d2ade882db0d', + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y': 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + 'd': 'jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI' + }), + ]), + { + 'payload': + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGF' + 'tcGxlLmNvbS9pc19yb290Ijp0cnVlfQ', + 'signatures': [ + { + 'protected': 'eyJhbGciOiJSUzI1NiJ9', + 'header': {'kid': '2010-12-29'}, + 'signature': 'cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZ' + 'mh7AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjb' + 'KBYNX4BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHl' + 'b1L07Qe7K0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZES' + 'c6BfI7noOPqvhJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AX' + 'LIhWkWywlVmtVrBp0igcN_IoypGlUPQGe77Rw' + }, + { + 'protected': 'eyJhbGciOiJFUzI1NiJ9', + 'header': {'kid': 'e9bc097a-ce51-4036-9562-d2ade882db0d'}, + 'signature': + 'DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8IS' + 'lSApmWQxfKTUJqPP3-Kg6NU1Q' + } + ] + }); + }); + group('Example JWS Using Flattened JWS JSON Serialization', () { + _doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + JsonWebKey.fromJson({ + 'kid': 'e9bc097a-ce51-4036-9562-d2ade882db0d', + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y': 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + 'd': 'jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI' + }), + { + 'payload': + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGF' + 'tcGxlLmNvbS9pc19yb290Ijp0cnVlfQ', + 'protected': 'eyJhbGciOiJFUzI1NiJ9', + 'header': {'kid': 'e9bc097a-ce51-4036-9562-d2ade882db0d'}, + 'signature': + 'DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8IS' + 'lSApmWQxfKTUJqPP3-Kg6NU1Q' + }); + }); + }); + group('Special algorithms JWS', () { + test('Signing with `none`', () async { + var payload = 'I am disguised'; + var builder = JsonWebSignatureBuilder()..content = payload; + + builder.addRecipient(null, algorithm: 'none'); + var jws = builder.build(); + + var keyStore = JsonWebKeyStore(); + jws = JsonWebSignature.fromCompactSerialization( + jws.toCompactSerialization()); + + expect(jws.getPayload(keyStore), throwsException); + + expect( + (await jws.getPayload(keyStore, allowedAlgorithms: ['none'])) + .stringContent, + payload); + }); + }); + + group('Issues', () { + test('bdaya-dev/jose#4', () async { + const jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6dGFsYW8uY28ja2V5LTIiLCJ0eXAiOiJKV1QifQ." + "eyJibG9ja2NoYWluT3B0aW9ucyI6eyJibmJTdXBwb3J0Ijp0cnVlLCJldGhlcmV1bVN1cHBvcnQiOnRy" + "dWUsImZhbnRvbVN1cHBvcnQiOnRydWUsImhlZGVyYVN1cHBvcnQiOnRydWUsImluZnVyYUFwaUtleSI6I" + "iIsImluZnVyYVJwY05vZGUiOmZhbHNlLCJwb2x5Z29uU3VwcG9ydCI6dHJ1ZSwidGV6b3NTdXBwb3J0Ijp" + "0cnVlLCJ0enByb0FwaUtleSI6IiIsInR6cHJvUnBjTm9kZSI6ZmFsc2V9LCJleHAiOjE3MDk0Njc1NjAuM" + "CwiZ2VuZXJhbE9wdGlvbnMiOnsiY29tcGFueUxvZ28iOiJodHRwczovL3RhbGFvLmNvL3N0YXRpYy9pbWc" + "vaWNvbi5wbmciLCJjb21wYW55TmFtZSI6Ik9yZ2EiLCJjb21wYW55V2Vic2l0ZSI6Im9yZ2EuZnIiLCJjdX" + "N0b21lclBsYW4iOiJmcmVlIiwicHJvZmlsZU5hbWUiOiJwcm9maWxlIiwicHJvZmlsZVZlcnNpb24iOiIyI" + "iwid2FsbGV0VHlwZSI6ImFsdG1lIn0sImhlbHBDZW50ZXJPcHRpb25zIjp7ImN1c3RvbUNoYXRTdXBwb3J0I" + "jp0cnVlLCJjdXN0b21DaGF0U3VwcG9ydE5hbWUiOiIiLCJjdXN0b21FbWFpbCI6IiIsImN1c3RvbUVtYWlsU" + "3VwcG9ydCI6ZmFsc2UsImRpc3BsYXlDaGF0U3VwcG9ydCI6dHJ1ZSwiZGlzcGxheUVtYWlsU3VwcG9ydCI6d" + "HJ1ZX0sImlhdCI6MTcwMTY5MTU2MC4wLCJpc3MiOiJkaWQ6d2ViOnRhbGFvLmNvIiwianRpIjoiOGY1OTgyN" + "DYtOTI5ZC0xMWVlLWJkNzQtMGExNjI4OTU4NTYwIiwicHVibGlzaGVkIjoiMjAyMy0xMS0yOCIsInNlbGZTb" + "3ZlcmVpZ25JZGVudGl0eU9wdGlvbnMiOnsiY3VzdG9tT2lkYzR2Y1Byb2ZpbGUiOnsiY2xpZW50QXV0aGVud" + "GljYXRpb24iOiJub25lIiwiY2xpZW50X2lkIjoiIiwiY2xpZW50X3NlY3JldCI6IiIsImNyeXB0b0hvbGRlc" + "kJpbmRpbmciOnRydWUsImRlZmF1bHREaWQiOiJkaWQ6a2V5OmVic2kiLCJvaWRjNHZjaURyYWZ0IjoiMTAiL" + "CJvaWRjNHZwRHJhZnQiOiIxMyIsInNjb3BlIjp0cnVlLCJzZWN1cml0eUxldmVsIjoibG93Iiwic2lvcHYyR" + "HJhZnQiOiIxMiIsInN1YmplY3RTeW50YXhlVHlwZSI6ImRpZCIsInVzZXJQaW5EaWdpdHMiOiI2In0sImRpc" + "3BsYXlNYW5hZ2VEZWNlbnRyYWxpemVkSWQiOnRydWUsImRpc3BsYXlTc2lBZHZhbmNlZFNldHRpbmdzIjp0c" + "nVlLCJkaXNwbGF5VmVyaWZpYWJsZURhdGFSZWdpc3RyeSI6dHJ1ZSwib2lkdjR2Y1Byb2ZpbGUiOiJjdXN0b" + "20ifSwic2V0dGluZ3NNZW51Ijp7ImRpc3BsYXlEZXZlbG9wZXJNb2RlIjp0cnVlLCJkaXNwbGF5SGVscENlb" + "nRlciI6dHJ1ZSwiZGlzcGxheVByb2ZpbGUiOnRydWV9LCJ2ZXJzaW9uIjoiMS42Iiwid2FsbGV0U2VjdXJpd" + "HlPcHRpb25zIjp7ImNvbmZpcm1TZWN1cml0eVZlcmlmaWVyQWNjZXNzIjpmYWxzZSwiZGlzcGxheVNlY3Vya" + "XR5QWR2YW5jZWRTZXR0aW5ncyI6dHJ1ZSwic2VjdXJlU2VjdXJpdHlBdXRoZW50aWNhdGlvbldpdGhQaW5Db" + "2RlIjp0cnVlLCJ2ZXJpZnlTZWN1cml0eUlzc3VlcldlYnNpdGVJZGVudGl0eSI6ZmFsc2V9fQ.02QVvroqJf" + "y5A-OajkG54htlAeiU0Q6oWEaFHTCMcDzh73p6dS86L4KznTv6c5NE0z_hh7fM3yWX1eiHGLET2w"; + const key = { + "crv": "P-256", + "kty": "EC", + "kid": "did:web:talao.co#key-2", + "x": "Bls7WaGu_jsharYBAzakvuSERIV_IFR2tS64e5p_Y_Q", + "y": "haeKjXQ9uzyK4Ind1W4SBUkR_9udjjx1OmKK4vl1jko" + }; + final jws = JsonWebSignature.fromCompactSerialization(jwt); + final keyStore = JsonWebKeyStore()..addKey(JsonWebKey.fromJson(key)); + final isVerified = await jws.verify(keyStore); + expect(isVerified, isTrue); + }); + test('appsup-dart/jose#57 - parse from JSON with multiple signatures', + () async { + final testData = { + 'payload': + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGF' + 'tcGxlLmNvbS9pc19yb290Ijp0cnVlfQ', + 'signatures': [ + { + 'protected': 'eyJhbGciOiJSUzI1NiJ9', + 'header': {'kid': '2010-12-29'}, + 'signature': 'cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZ' + 'mh7AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjb' + 'KBYNX4BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHl' + 'b1L07Qe7K0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZES' + 'c6BfI7noOPqvhJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AX' + 'LIhWkWywlVmtVrBp0igcN_IoypGlUPQGe77Rw' + }, + { + 'protected': 'eyJhbGciOiJFUzI1NiJ9', + 'header': {'kid': 'e9bc097a-ce51-4036-9562-d2ade882db0d'}, + 'signature': + 'DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8IS' + 'lSApmWQxfKTUJqPP3-Kg6NU1Q' + } + ] + }; + final jsonJWS = jsonEncode(testData); + final jwsMap = jsonDecode(jsonJWS) as Map; + final jws = JsonWebSignature.fromJson(jwsMap); + expect(jws.recipients, isNotEmpty); + expect(jws.toJson(), testData); + }); + }); +} + +void _doTests(dynamic payload, dynamic key, dynamic encoded, + {List? allowedAlgorithms}) { + var jws = encoded is String + ? JsonWebSignature.fromCompactSerialization(encoded) + : JsonWebSignature.fromJson(encoded); + var keys = key is JsonWebKeySet + ? key + : JsonWebKeySet.fromKeys(key == null ? [] : [key]); + var context = JsonWebKeyStore()..addKeySet(keys); + + Future expectPayload( + JoseObject jose, { + List? allowedAlgorithms, + }) async { + var content = + await jose.getPayload(context, allowedAlgorithms: allowedAlgorithms); + if (payload is String) { + expect(content.stringContent, payload); + } else if (payload is Map) { + expect(content.jsonContent, payload); + } else if (payload is List) { + expect(content.data, payload); + } + } + + test('decode', () { + expectPayload(jws, allowedAlgorithms: allowedAlgorithms); + if (encoded is String) { + expect(jws.toCompactSerialization(), encoded); + } else { + expect(jws.toJson(), encoded); + } + }); + test('verify', () async { + await expectPayload(jws, allowedAlgorithms: allowedAlgorithms); + }); + test('create', () async { + var builder = JsonWebSignatureBuilder()..content = payload; + + if (keys.keys.isEmpty) { + builder.addRecipient(null, algorithm: 'none'); + } else { + for (var key in keys.keys) { + builder.addRecipient(key); + } + } + + var jws = builder.build(); + + if (encoded is String) jws.toCompactSerialization(); + await expectPayload(jws, allowedAlgorithms: allowedAlgorithms); + }); +} diff --git a/packages/jose_plus/test/jwt_claims_test.dart b/packages/jose_plus/test/jwt_claims_test.dart new file mode 100644 index 00000000..383f5c76 --- /dev/null +++ b/packages/jose_plus/test/jwt_claims_test.dart @@ -0,0 +1,199 @@ +import 'package:clock/clock.dart'; +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonWebTokenClaims getters', () { + test('exposes all registered claim getters', () { + final claims = JsonWebTokenClaims.fromJson({ + 'iss': 'https://issuer.example.com', + 'sid': 'session-123', + 'sub': 'subject-42', + 'aud': ['client-a', 'client-b'], + 'exp': 1300819380, + 'nbf': 1300819000, + 'iat': 1300818900, + 'jti': 'unique-id', + }); + + expect(claims.issuer, Uri.parse('https://issuer.example.com')); + expect(claims.sid, 'session-123'); + expect(claims.subject, 'subject-42'); + expect(claims.audience, ['client-a', 'client-b']); + expect(claims.expiry, + DateTime.fromMillisecondsSinceEpoch(1300819380 * 1000)); + expect(claims.notBefore, + DateTime.fromMillisecondsSinceEpoch(1300819000 * 1000)); + expect(claims.issuedAt, + DateTime.fromMillisecondsSinceEpoch(1300818900 * 1000)); + expect(claims.jwtId, 'unique-id'); + }); + + test('audience accepts a single string value', () { + final claims = JsonWebTokenClaims.fromJson({'aud': 'only-client'}); + expect(claims.audience, ['only-client']); + }); + }); + + group('JsonWebTokenClaims.validate', () { + // A fixed clock so expiry math is deterministic. + final now = DateTime.utc(2022, 1, 1, 12, 0, 0); + final nowSeconds = now.millisecondsSinceEpoch ~/ 1000; + + Iterable validateAt( + DateTime at, + JsonWebTokenClaims claims, { + Duration expiryTolerance = Duration.zero, + Uri? issuer, + String? clientId, + }) { + return withClock(Clock.fixed(at), () { + return claims + .validate( + expiryTolerance: expiryTolerance, + issuer: issuer, + clientId: clientId, + ) + .toList(); + }); + } + + test('a valid, unexpired token yields no exceptions', () { + final claims = JsonWebTokenClaims.fromJson({ + 'iss': 'https://issuer.example.com', + 'aud': ['my-client'], + 'exp': nowSeconds + 3600, + }); + + final errors = validateAt( + now, + claims, + issuer: Uri.parse('https://issuer.example.com'), + clientId: 'my-client', + ); + expect(errors, isEmpty); + }); + + test('reports an expired token', () { + final claims = JsonWebTokenClaims.fromJson({ + 'exp': nowSeconds - 3600, + }); + + final errors = validateAt(now, claims); + expect(errors, hasLength(1)); + expect(errors.single.message, contains('expired')); + }); + + test('respects the expiry tolerance', () { + final claims = JsonWebTokenClaims.fromJson({ + 'exp': nowSeconds - 30, + }); + + // Within tolerance -> accepted. + expect( + validateAt(now, claims, expiryTolerance: const Duration(minutes: 1)), + isEmpty, + ); + // Beyond tolerance -> rejected. + expect( + validateAt(now, claims, expiryTolerance: const Duration(seconds: 5)), + hasLength(1), + ); + }); + + test('reports an issuer mismatch', () { + final claims = JsonWebTokenClaims.fromJson({ + 'iss': 'https://evil.example.com', + 'exp': nowSeconds + 3600, + }); + + final errors = validateAt( + now, + claims, + issuer: Uri.parse('https://issuer.example.com'), + ); + expect(errors, hasLength(1)); + expect(errors.single.message, contains('Issuer does not match')); + }); + + test('reports a clientId not present in the audience', () { + final claims = JsonWebTokenClaims.fromJson({ + 'aud': ['someone-else'], + 'exp': nowSeconds + 3600, + }); + + final errors = validateAt(now, claims, clientId: 'my-client'); + expect(errors, hasLength(1)); + expect(errors.single.message, contains('clientId')); + }); + + test('reports a missing audience when clientId is required', () { + final claims = JsonWebTokenClaims.fromJson({ + 'exp': nowSeconds + 3600, + }); + + final errors = validateAt(now, claims, clientId: 'my-client'); + expect(errors, hasLength(1)); + expect(errors.single.message, contains('clientId')); + }); + + test('accumulates multiple validation errors', () { + final claims = JsonWebTokenClaims.fromJson({ + 'iss': 'https://evil.example.com', + 'aud': ['someone-else'], + 'exp': nowSeconds - 3600, + }); + + final errors = validateAt( + now, + claims, + issuer: Uri.parse('https://issuer.example.com'), + clientId: 'my-client', + ); + expect(errors, hasLength(3)); + }); + }); + + group('JsonWebToken verification state', () { + final key = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', + })!; + + String signedJwt() { + return (JsonWebSignatureBuilder() + ..jsonContent = {'sub': 'user', 'exp': 9999999999} + ..setProtectedHeader('typ', 'JWT') + ..addRecipient(key, algorithm: 'HS256')) + .build() + .toCompactSerialization(); + } + + test('unverified() leaves isVerified null until a verify attempt', () { + final jwt = JsonWebToken.unverified(signedJwt()); + expect(jwt.isVerified, isNull); + expect(jwt.claims.subject, 'user'); + }); + + test('verify() returns true and sets isVerified for the correct key', + () async { + final jwt = JsonWebToken.unverified(signedJwt()); + final ok = await jwt.verify(JsonWebKeyStore()..addKey(key)); + expect(ok, isTrue); + expect(jwt.isVerified, isTrue); + }); + + test('verify() returns false and sets isVerified for a wrong key', + () async { + final jwt = JsonWebToken.unverified(signedJwt()); + final wrongKey = JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': 'AAECAwQFBgcICQoLDA0ODw', + })!; + final ok = await jwt.verify(JsonWebKeyStore()..addKey(wrongKey)); + expect(ok, isFalse); + expect(jwt.isVerified, isFalse); + }); + }); +} diff --git a/packages/jose_plus/test/jwt_test.dart b/packages/jose_plus/test/jwt_test.dart new file mode 100644 index 00000000..fe025827 --- /dev/null +++ b/packages/jose_plus/test/jwt_test.dart @@ -0,0 +1,217 @@ +@Tags(['slow-web-crypto']) +library; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWT Examples from RFC7519', () { + var context = JsonWebKeyStore() + ..addKey(JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': + 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + })) + ..addKey(JsonWebKey.fromJson( + { + 'kty': 'RSA', + 'n': 'sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1Wl' + 'UzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDpre' + 'cbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_' + '7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBI' + 'Y2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU' + '7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw', + 'e': 'AQAB', + 'd': 'VFCWOqXr8nvZNyaaJLXdnNPXZKRaWCjkU5Q2egQQpTBMwhprMzWzpR8Sxq' + '1OPThh_J6MUD8Z35wky9b8eEO0pwNS8xlh1lOFRRBoNqDIKVOku0aZb-ry' + 'nq8cxjDTLZQ6Fz7jSjR1Klop-YKaUHc9GsEofQqYruPhzSA-QgajZGPbE_' + '0ZaVDJHfyd7UUBUKunFMScbflYAAOYJqVIVwaYR5zWEEceUjNnTNo_CVSj' + '-VvXLO5VZfCUAVLgW4dpf1SrtZjSt34YLsRarSb127reG_DUwg9Ch-Kyvj' + 'T1SkHgUWRVGcyly7uvVGRSDwsXypdrNinPA4jlhoNdizK2zF2CWQ', + 'p': '9gY2w6I6S6L0juEKsbeDAwpd9WMfgqFoeA9vEyEUuk4kLwBKcoe1x4HG68' + 'ik918hdDSE9vDQSccA3xXHOAFOPJ8R9EeIAbTi1VwBYnbTp87X-xcPWlEP' + 'krdoUKW60tgs1aNd_Nnc9LEVVPMS390zbFxt8TN_biaBgelNgbC95sM', + 'q': 'uKlCKvKv_ZJMVcdIs5vVSU_6cPtYI1ljWytExV_skstvRSNi9r66jdd9-y' + 'BhVfuG4shsp2j7rGnIio901RBeHo6TPKWVVykPu1iYhQXw1jIABfw-MVsN' + '-3bQ76WLdt2SDxsHs7q7zPyUyHXmps7ycZ5c72wGkUwNOjYelmkiNS0', + 'dp': 'w0kZbV63cVRvVX6yk3C8cMxo2qCM4Y8nsq1lmMSYhG4EcL6FWbX5h9yuv' + 'ngs4iLEFk6eALoUS4vIWEwcL4txw9LsWH_zKI-hwoReoP77cOdSL4AVcra' + 'Hawlkpyd2TWjE5evgbhWtOxnZee3cXJBkAi64Ik6jZxbvk-RR3pEhnCs', + 'dq': 'o_8V14SezckO6CNLKs_btPdFiO9_kC1DsuUTd2LAfIIVeMZ7jn1Gus_Ff' + '7B7IVx3p5KuBGOVF8L-qifLb6nQnLysgHDh132NDioZkhH7mI7hPG-PYE_' + 'odApKdnqECHWw0J-F0JWnUd6D2B_1TvF9mXA2Qx-iGYn8OVV1Bsmp6qU', + 'qi': 'eNho5yRBEBxhGBtQRww9QirZsB66TrfFReG_CcteI1aCneT0ELGhYlRlC' + 'tUkTRclIfuEPmNsNDPbLoLqqCVznFbvdB7x-Tl-m0l_eFTj2KiqwGqE9PZ' + 'B9nNTwMVvH3VRRSLWACvPnSiwP8N5Usy-WRXS-V7TbpxIhvepTfE0NNo' + }, + )) + ..addKey(JsonWebKey.fromJson({ + 'kty': 'RSA', + 'n': 'ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddx' + 'HmfHQp-Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMs' + 'D1W_YpRPEwOWvG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSH' + 'SXndS5z5rexMdbBYUsLA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdV' + 'MTBQY4uDZlxvb3qCo5ZwKh9kG4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8' + 'NAco9_h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXpoQ', + 'e': 'AQAB', + 'd': 'Eq5xpGnNCivDflJsRQBXHx1hdR1k6Ulwe2JZD50LpXyWPEAeP88vLNO97I' + 'jlA7_GQ5sLKMgvfTeXZx9SE-7YwVol2NXOoAJe46sui395IW_GO-pWJ1O0' + 'BkTGoVEn2bKVRUCgu-GjBVaYLU6f3l9kJfFNS3E0QbVdxzubSu3Mkqzjkn' + '439X0M_V51gfpRLI9JYanrC4D4qAdGcopV_0ZHHzQlBjudU2QvXt4ehNYT' + 'CBr6XCLQUShb1juUO1ZdiYoFaFQT5Tw8bGUl_x_jTj3ccPDVZFD9pIuhLh' + 'BOneufuBiB4cS98l2SR_RQyGWSeWjnczT0QU91p1DhOVRuOopznQ', + 'p': '4BzEEOtIpmVdVEZNCqS7baC4crd0pqnRH_5IB3jw3bcxGn6QLvnEtfdUdi' + 'YrqBdss1l58BQ3KhooKeQTa9AB0Hw_Py5PJdTJNPY8cQn7ouZ2KKDcmnPG' + 'BY5t7yLc1QlQ5xHdwW1VhvKn-nXqhJTBgIPgtldC-KDV5z-y2XDwGUc', + 'q': 'uQPEfgmVtjL0Uyyx88GZFF1fOunH3-7cepKmtH4pxhtCoHqpWmT8YAmZxa' + 'ewHgHAjLYsp1ZSe7zFYHj7C6ul7TjeLQeZD_YwD66t62wDmpe_HlB-TnBA' + '-njbglfIsRLtXlnDzQkv5dTltRJ11BKBBypeeF6689rjcJIDEz9RWdc', + 'dp': 'BwKfV3Akq5_MFZDFZCnW-wzl-CCo83WoZvnLQwCTeDv8uzluRSnm71I3Q' + 'CLdhrqE2e9YkxvuxdBfpT_PI7Yz-FOKnu1R6HsJeDCjn12Sk3vmAktV2zb' + '34MCdy7cpdTh_YVr7tss2u6vneTwrA86rZtu5Mbr1C1XsmvkxHQAdYo0', + 'dq': 'h_96-mK1R_7glhsum81dZxjTnYynPbZpHziZjeeHcXYsXaaMwkOlODsWa' + '7I9xXDoRwbKgB719rrmI2oKr6N3Do9U0ajaHF-NKJnwgjMd2w9cjz3_-ky' + 'NlxAr2v4IKhGNpmM5iIgOS1VZnOZ68m6_pbLBSp3nssTdlqvd0tIiTHU', + 'qi': 'IYd7DHOhrWvxkwPQsRM2tOgrjbcrfvtQJipd-DlcxyVuuM9sQLdgjVk2o' + 'y26F0EmpScGLq2MowX7fhd_QJQ3ydy5cY7YIBi87w93IKLEdfnbJtoOPLU' + 'W0ITrJReOgo1cq9SbsxYawBgfp_gh6A5603k2-ZQwVK0JKSHuLFkuQ3U' + })); + + void doTests(dynamic payload, dynamic encoded, [bool verify = true]) { + test('decode', () async { + var jwt = await JsonWebToken.decodeAndVerify(encoded, context, + allowedArguments: verify ? null : ['none']); + + expect(jwt.toCompactSerialization(), encoded); + expect(jwt.claims.toJson(), payload); + }); + test('create', () { +/* + var jws = JsonWebSignature.unsigned(payload); + + for (var key in keys.keys) { + jws = jws.sign(key); + } + + expect(jws.verify(keys), true); + _expectPayload(jws); +*/ + }); + } + + group('Example JWT', () { + doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.' + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + }); + group('Example Unsecured JWT', () { + doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + 'eyJhbGciOiJub25lIn0.' + 'eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFt' + 'cGxlLmNvbS9pc19yb290Ijp0cnVlfQ.', + false); + }); + group('Example Encrypted JWT', () { + doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + 'eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0.' + 'QR1Owv2ug2WyPBnbQrRARTeEk9kDO2w8qDcjiHnSJflSdv1iNqhWXaKH4MqAkQtM' + 'oNfABIPJaZm0HaA415sv3aeuBWnD8J-Ui7Ah6cWafs3ZwwFKDFUUsWHSK-IPKxLG' + 'TkND09XyjORj_CHAgOPJ-Sd8ONQRnJvWn_hXV1BNMHzUjPyYwEsRhDhzjAD26ima' + 'sOTsgruobpYGoQcXUwFDn7moXPRfDE8-NoQX7N7ZYMmpUDkR-Cx9obNGwJQ3nM52' + 'YCitxoQVPzjbl7WBuB7AohdBoZOdZ24WlN1lVIeh8v1K4krB8xgKvRU8kgFrEn_a' + '1rZgN5TiysnmzTROF869lQ.' + 'AxY8DCtDaGlsbGljb3RoZQ.' + 'MKOle7UQrG6nSxTLX6Mqwt0orbHvAKeWnDYvpIAeZ72deHxz3roJDXQyhxx0wKaM' + 'HDjUEOKIwrtkHthpqEanSBNYHZgmNOV7sln1Eu9g3J8.' + 'fiK51VwhsxJ-siBMR-YFiA'); + }); + group('Example Nested JWT', () { + doTests( + {'iss': 'joe', 'exp': 1300819380, 'http://example.com/is_root': true}, + 'eyJhbGciOiJSU0ExXzUiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiY3R5IjoiSldU' + 'In0.' + 'g_hEwksO1Ax8Qn7HoN-BVeBoa8FXe0kpyk_XdcSmxvcM5_P296JXXtoHISr_DD_M' + 'qewaQSH4dZOQHoUgKLeFly-9RI11TG-_Ge1bZFazBPwKC5lJ6OLANLMd0QSL4fYE' + 'b9ERe-epKYE3xb2jfY1AltHqBO-PM6j23Guj2yDKnFv6WO72tteVzm_2n17SBFvh' + 'DuR9a2nHTE67pe0XGBUS_TK7ecA-iVq5COeVdJR4U4VZGGlxRGPLRHvolVLEHx6D' + 'YyLpw30Ay9R6d68YCLi9FYTq3hIXPK_-dmPlOUlKvPr1GgJzRoeC9G5qCvdcHWsq' + 'JGTO_z3Wfo5zsqwkxruxwA.' + 'UmVkbW9uZCBXQSA5ODA1Mg.' + 'VwHERHPvCNcHHpTjkoigx3_ExK0Qc71RMEParpatm0X_qpg-w8kozSjfNIPPXiTB' + 'BLXR65CIPkFqz4l1Ae9w_uowKiwyi9acgVztAi-pSL8GQSXnaamh9kX1mdh3M_TT' + '-FZGQFQsFhu0Z72gJKGdfGE-OE7hS1zuBD5oEUfk0Dmb0VzWEzpxxiSSBbBAzP10' + 'l56pPfAtrjEYw-7ygeMkwBl6Z_mLS6w6xUgKlvW6ULmkV-uLC4FUiyKECK4e3WZY' + 'Kw1bpgIqGYsw2v_grHjszJZ-_I5uM-9RA8ycX9KqPRp9gc6pXmoU_-27ATs9XCvr' + 'ZXUtK2902AUzqpeEUJYjWWxSNsS-r1TJ1I-FMJ4XyAiGrfmo9hQPcNBYxPz3GQb2' + '8Y5CLSQfNgKSGt0A4isp1hBUXBHAndgtcslt7ZoQJaKe_nNJgNliWtWpJ_ebuOpE' + 'l8jdhehdccnRMIwAmU1n7SPkmhIl1HlSOpvcvDfhUN5wuqU955vOBvfkBOh5A11U' + 'zBuo2WlgZ6hYi9-e3w29bR0C2-pp3jbqxEDw3iWaf2dc5b-LnR0FEYXvI_tYk5rd' + '_J9N0mg0tQ6RbpxNEMNoA9QWk5lgdPvbh9BaO195abQ.' + 'AVO9iT5AV4CzvDJCdhSFlQ'); + }); + }); + + test('JsonWebTokenClaims can handle doubles in expiration', () { + final claims = JsonWebTokenClaims.fromJson({'exp': 1300819380.0}); + expect(claims.expiry, DateTime.fromMillisecondsSinceEpoch(1300819380000)); + }); + + group('Verification key selection', () { + test('JWT with header jwk is rejected when no matching trusted key exists', + () async { + final trustedKey = JsonWebKey.generate('RS256'); + final keyStore = JsonWebKeyStore()..addKey(trustedKey); + + final alternateKey = JsonWebKey.generate('RS256'); + final claims = JsonWebTokenClaims.fromJson({ + 'sub': 'user-2', + 'iss': 'https://trusted-issuer.example.com', + 'aud': 'my-application', + 'exp': DateTime.now() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000, + 'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }); + final builder = JsonWebSignatureBuilder() + ..jsonContent = claims.toJson() + ..setProtectedHeader('typ', 'JWT') + ..addRecipient(alternateKey, algorithm: 'RS256') + ..setProtectedHeader( + 'jwk', + JsonWebKey.fromCryptoKeys( + publicKey: alternateKey.cryptoKeyPair.publicKey) + .toJson()); + final jwtCompact = builder.build().toCompactSerialization(); + + expect( + () => JsonWebToken.decodeAndVerify(jwtCompact, keyStore), + throwsA(isA()), + ); + }); + }); + + group('JWT with ES256K signature', () { + test('JWT with ES256K signature', () async { + var key = JsonWebKey.fromJson({ + 'kty': 'EC', + 'd': 'e8HThqO0wR_Qw4pNIb80Cs0mYuCSqT6BSQj-o-tKTrg', + 'x': 'A3hkIubgDggcoHzmVdXIm11gZ7UMaOa71JVf1eCifD8', + 'y': 'ejpRwmCvNMdXMOjR2DodOt09OLPgNUrcKA9hBslaFU0', + 'crv': 'P-256K', + 'kid': '123' + }); + + var jwt = JsonWebToken.unverified( + 'eyJhbGciOiJFUzI1NksiLCJraWQiOiIxMjMiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJ5b3UiLCJpc3MiOiJtZSIsImV4cCI6MTU3NjQ0Mzg2NS4wLCJpYXQiOjE1NzY0NDAyNjUsIm5iZiI6MTU3NjQ0MDI2NX0.nngSyreix-Ri0H1lC4PRGYLNEktMDUag22VmSYe_SRJFd_Oh-Qag1XSLr1Pq0puym8KSVVuPYCzIh5rsuAFH6g'); + + var verified = await jwt.verify(JsonWebKeyStore()..addKey(key)); + expect(verified, true); + }); + }); +} diff --git a/packages/jose_plus/test/library_surface_test.dart b/packages/jose_plus/test/library_surface_test.dart new file mode 100644 index 00000000..a8b6537c --- /dev/null +++ b/packages/jose_plus/test/library_surface_test.dart @@ -0,0 +1,27 @@ +// Ensures the package-name-matching entrypoint (`jose_plus.dart`) is +// actually loaded by the test suite, so its re-exported surface stays +// visible to coverage tooling even though the rest of the suite only ever +// imports the legacy `jose.dart` entrypoint. +import 'package:jose_plus/jose_plus.dart'; +import 'package:test/test.dart'; + +void main() { + group('jose_plus library surface', () { + test( + 'JsonWebAlgorithm.getByName resolves RS256 to its RFC 7518 metadata', + () { + final alg = JsonWebAlgorithm.getByName('RS256'); + expect(alg.type, 'RSA'); + expect(alg.use, 'sig'); + expect(alg.minKeyBitLength, 2048); + }, + ); + + test('JsonWebAlgorithm.getByName throws for an unsupported name', () { + expect( + () => JsonWebAlgorithm.getByName('not-a-real-alg'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/jose_plus/test/pem/ec256.key b/packages/jose_plus/test/pem/ec256.key new file mode 100644 index 00000000..ebcb2872 --- /dev/null +++ b/packages/jose_plus/test/pem/ec256.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKAAFL0cr5Xr+1Kjj4wGkbRqMQl7NZXDSq+h5c9T+G5FoAoGCCqGSM49 +AwEHoUQDQgAEyjQkWtvC2FpbPR2lzKP4Ucaz5cpYGFVUKjhFasI3kz4Uv2U1KipT +4BGmdhy82EeEcIwDWSbsOFKXp8vShXg7Wg== +-----END EC PRIVATE KEY----- diff --git a/packages/jose_plus/test/pem/ec256.pub.key b/packages/jose_plus/test/pem/ec256.pub.key new file mode 100644 index 00000000..b00bb50e --- /dev/null +++ b/packages/jose_plus/test/pem/ec256.pub.key @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyjQkWtvC2FpbPR2lzKP4Ucaz5cpY +GFVUKjhFasI3kz4Uv2U1KipT4BGmdhy82EeEcIwDWSbsOFKXp8vShXg7Wg== +-----END PUBLIC KEY----- diff --git a/packages/jose_plus/test/pem/ec256k.key b/packages/jose_plus/test/pem/ec256k.key new file mode 100644 index 00000000..c133ac9c --- /dev/null +++ b/packages/jose_plus/test/pem/ec256k.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHQCAQEEIAHK7ICPc0AEP3FqbVKqGIgXQ5mhY9Yz+/aT0B9ky+eCoAcGBSuBBAAK +oUQDQgAEHl93cVOqv+uH/6VPETRNzP8JLa/kr/OOAm51H0UVnxUbHyVEC1pjKF3p +cwaYAxFXihxX6heAocbwufr0r7VmiA== +-----END EC PRIVATE KEY----- diff --git a/packages/jose_plus/test/pem/ec256k.pub.key b/packages/jose_plus/test/pem/ec256k.pub.key new file mode 100644 index 00000000..a1097eea --- /dev/null +++ b/packages/jose_plus/test/pem/ec256k.pub.key @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEHl93cVOqv+uH/6VPETRNzP8JLa/kr/OO +Am51H0UVnxUbHyVEC1pjKF3pcwaYAxFXihxX6heAocbwufr0r7VmiA== +-----END PUBLIC KEY----- diff --git a/packages/jose_plus/test/pem/rsa.key b/packages/jose_plus/test/pem/rsa.key new file mode 100644 index 00000000..568d0ee0 --- /dev/null +++ b/packages/jose_plus/test/pem/rsa.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAMuu7cqjidt7O5s7 +NpZTgKwZD8eY8a83vl+Qsj9zqgBpS2ymfMxj3wtJYyzrU+hHtkVSYdwpv4JiPtpm +YKL0CKJNBmxvZ3EY6Wn7RtY2g1az+wAdewHt+jUFWRxs5vswm4bqHv7AxF+vlaRV +X6ToeRZoaKX8gTRb6V6JZ9b6CE5dAgMBAAECgYEAtsOST8AK1UxwocuyIZVv4fzi +8eDFDMpiInI93z20D4ZrzWRemzAKVaBWQ1ouAkFGxlEzylATrbTmXSZ8E/wROaYd +5wJtU0KuzVro70+5Hy5dEuyu7CV0spMmbBpKj2ycuzU7O7nNEK541WwkpAz65/LE +uu2mVOB1mmSaDzUiQ+kCQQD2FqFxl0U8iHjwNtA3QhQ7VTetRuOxH0xwX3QjA7Zp +481Wta+taaRcUdeMoRK/CXs6PQv0QyZlk8ledW9Z1v03AkEA0+MRVKJFgqyi9Hwx +dSGxvmmUeD2NbZCY/KYloCrP3iEILdsQqYfxZ2fCNckYq/PNlZEfNOSePFv+6JDb +X+57CwJAUxu/eDJDMrdQSKfqV88cMBGOlRlsyiOs2AKUoUKet1I0xcTkSKd116vY +JOk9AIF+OWPmhp6q+AEaL6lvaqLdrwJBAIQTLxNVTwTEPZMt8tozVYr+XtiQbZ7V +GjeqDRwIc6ffaSzEN1+laFlveMR0BFfy0bwgSJZ8xxGSupMW8Fec3L8CQQC01ZQr +J+rd86RKG0GgNv9TwnyY0c5G01E6qsSH7gkm/moPG/IsPrOpWC9sdx2qQAhriISQ +xQ1TK4rvVeBM1H0g +-----END PRIVATE KEY----- diff --git a/packages/jose_plus/test/pem/rsa.pub.key b/packages/jose_plus/test/pem/rsa.pub.key new file mode 100644 index 00000000..1ca881aa --- /dev/null +++ b/packages/jose_plus/test/pem/rsa.pub.key @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLru3Ko4nbezubOzaWU4CsGQ/H +mPGvN75fkLI/c6oAaUtspnzMY98LSWMs61PoR7ZFUmHcKb+CYj7aZmCi9AiiTQZs +b2dxGOlp+0bWNoNWs/sAHXsB7fo1BVkcbOb7MJuG6h7+wMRfr5WkVV+k6HkWaGil +/IE0W+leiWfW+ghOXQIDAQAB +-----END PUBLIC KEY----- diff --git a/packages/jose_plus/test/pss_eddsa_jws_test.dart b/packages/jose_plus/test/pss_eddsa_jws_test.dart new file mode 100644 index 00000000..e9ecbd80 --- /dev/null +++ b/packages/jose_plus/test/pss_eddsa_jws_test.dart @@ -0,0 +1,121 @@ +@Tags(['slow-web-crypto']) +library; + +import 'package:jose_plus/jose.dart'; +import 'package:test/test.dart'; + +void main() { + group('RSASSA-PSS JWS', () { + for (final alg in ['PS256', 'PS384', 'PS512']) { + test('$alg sign + verify round trip', () async { + final key = JsonWebKey.generate(alg); + + final builder = JsonWebSignatureBuilder() + ..content = 'It works with $alg!' + ..addRecipient(key, algorithm: alg); + final jws = builder.build(); + + // Re-parse from the compact serialization to exercise the full path. + final parsed = JsonWebSignature.fromCompactSerialization( + jws.toCompactSerialization()); + + final keyStore = JsonWebKeyStore()..addKey(key); + expect(await parsed.verify(keyStore), isTrue); + final payload = await parsed.getPayload(keyStore); + expect(payload.stringContent, 'It works with $alg!'); + }); + + test('$alg verification fails with the wrong key', () async { + final key = JsonWebKey.generate(alg); + final builder = JsonWebSignatureBuilder() + ..content = 'tamper-evident' + ..addRecipient(key, algorithm: alg); + final jws = builder.build(); + final parsed = JsonWebSignature.fromCompactSerialization( + jws.toCompactSerialization()); + + final otherKey = JsonWebKey.generate(alg); + final keyStore = JsonWebKeyStore()..addKey(otherKey); + expect(await parsed.verify(keyStore), isFalse); + }); + } + }); + + group('EdDSA JWS (RFC 8037 Appendix A.4)', () { + // RFC 8037 Appendix A.4 published compact JWS and public JWK (A.2). + const compactJws = + 'eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.' + 'hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5Bh' + 'VsPt9g7sVvpAr_MuM0KAg'; + + final publicKey = JsonWebKey.fromJson({ + 'kty': 'OKP', + 'crv': 'Ed25519', + 'x': '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo', + }); + + test('verifies the published JWS with the public key', () async { + final jws = JsonWebSignature.fromCompactSerialization(compactJws); + final keyStore = JsonWebKeyStore()..addKey(publicKey); + expect(await jws.verify(keyStore), isTrue); + }); + + test('rejects a tampered payload', () async { + // Flip the last char of the payload segment. + final parts = compactJws.split('.'); + final tamperedPayload = '${parts[1].substring(0, parts[1].length - 1)}A'; + final tampered = '${parts[0]}.$tamperedPayload.${parts[2]}'; + final jws = JsonWebSignature.fromCompactSerialization(tampered); + final keyStore = JsonWebKeyStore()..addKey(publicKey); + expect(await jws.verify(keyStore), isFalse); + }); + + test('full key sign + verify round trip', () async { + final key = JsonWebKey.generate('EdDSA'); + final builder = JsonWebSignatureBuilder() + ..content = 'EdDSA works end to end' + ..addRecipient(key, algorithm: 'EdDSA'); + final jws = builder.build(); + final parsed = JsonWebSignature.fromCompactSerialization( + jws.toCompactSerialization()); + + final keyStore = JsonWebKeyStore()..addKey(key); + expect(await parsed.verify(keyStore), isTrue); + expect((await parsed.getPayload(keyStore)).stringContent, + 'EdDSA works end to end'); + }); + }); + + group('RSASSA-PSS JWS (RFC 7520 §4.2 PS384 published vector)', () { + // §3.3 RSA public key (Figure 3) + §4.2.3 compact JWS (Figure 20), + // extracted verbatim from RFC 7520. PS384 verification is independent of + // the random salt (recovered from the signature during EMSA-PSS-VERIFY), so + // this is a deterministic cross-implementation KAT proving our PSS + // salt/MGF/digest wiring matches other implementations. + final publicKey = JsonWebKey.fromJson({ + 'kty': 'RSA', + 'kid': 'bilbo.baggins@hobbiton.example', + 'n': + 'n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw', + 'e': 'AQAB', + }); + const compactJws = + 'eyJhbGciOiJQUzM4NCIsImtpZCI6ImJpbGJvLmJhZ2dpbnNAaG9iYml0b24uZXhhbXBsZSJ9.SXTigJlzIGEgZGFuZ2Vyb3VzIGJ1c2luZXNzLCBGcm9kbywgZ29pbmcgb3V0IHlvdXIgZG9vci4gWW91IHN0ZXAgb250byB0aGUgcm9hZCwgYW5kIGlmIHlvdSBkb24ndCBrZWVwIHlvdXIgZmVldCwgdGhlcmXigJlzIG5vIGtub3dpbmcgd2hlcmUgeW91IG1pZ2h0IGJlIHN3ZXB0IG9mZiB0by4.cu22eBqkYDKgIlTpzDXGvaFfz6WGoz7fUDcfT0kkOy42miAh2qyBzk1xEsnk2IpN6-tPid6VrklHkqsGqDqHCdP6O8TTB5dDDItllVo6_1OLPpcbUrhiUSMxbbXUvdvWXzg-UD8biiReQFlfz28zGWVsdiNAUf8ZnyPEgVFn442ZdNqiVJRmBqrYRXe8P_ijQ7p8Vdz0TTrxUeT3lm8d9shnr2lfJT8ImUjvAA2Xez2Mlp8cBE5awDzT0qI0n6uiP1aCN_2_jLAeQTlqRHtfa64QQSUmFAAjVKPbByi7xho0uTOcbH510a6GYmJUAfmWjwZ6oD4ifKo8DYM-X72Eaw'; + + test('verifies the published PS384 JWS with the public key', () async { + final jws = JsonWebSignature.fromCompactSerialization(compactJws); + final keyStore = JsonWebKeyStore()..addKey(publicKey); + expect(await jws.verify(keyStore), isTrue); + }); + + test('rejects a tampered signature', () async { + final parts = compactJws.split('.'); + final sig = parts[2]; + final tampered = '${parts[0]}.${parts[1]}.' + '${sig.substring(0, sig.length - 1)}${sig.endsWith('A') ? 'B' : 'A'}'; + final jws = JsonWebSignature.fromCompactSerialization(tampered); + final keyStore = JsonWebKeyStore()..addKey(publicKey); + expect(await jws.verify(keyStore), isFalse); + }); + }); +} diff --git a/packages/jose_plus/test/util_test.dart b/packages/jose_plus/test/util_test.dart new file mode 100644 index 00000000..14cdb73e --- /dev/null +++ b/packages/jose_plus/test/util_test.dart @@ -0,0 +1,160 @@ +import 'package:jose_plus/src/util.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonObject', () { + test('equality and hashCode are based on encoded content', () { + final a = JsonObject.from({'a': 1, 'b': 'two'}); + final b = JsonObject.from({'a': 1, 'b': 'two'}); + final c = JsonObject.from({'a': 2, 'b': 'two'}); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a, isNot(equals(c))); + // Comparing against a non-JsonObject value returns false. + final Object other = 'not a json object'; + expect(a == other, isFalse); + }); + + test('toString reflects the underlying map', () { + final o = JsonObject.from({'x': 1}); + expect(o.toString(), '{x: 1}'); + }); + + test('toBytes round-trips through the base64 encoding', () { + final o = JsonObject.from({'hello': 'world'}); + final decoded = JsonObject.fromBytes(o.toBytes()); + expect(decoded, equals(o)); + expect(decoded['hello'], 'world'); + }); + + test('from() rejects values that are not valid JSON', () { + expect( + () => JsonObject.from({'bad': DateTime.now()}), + throwsA(isA()), + ); + }); + + test('getTyped converts Uri values', () { + final o = JsonObject.from({'u': 'https://example.com/path'}); + expect(o.getTyped('u'), Uri.parse('https://example.com/path')); + }); + + test('getTyped converts Duration values from seconds', () { + final o = JsonObject.from({'d': 90}); + expect(o.getTyped('d'), const Duration(seconds: 90)); + }); + + test('getTyped converts DateTime values from unix seconds', () { + final o = JsonObject.from({'t': 1300819380}); + expect( + o.getTyped('t'), + DateTime.fromMillisecondsSinceEpoch(1300819380000), + ); + }); + + test('getTyped returns null for absent keys', () { + final o = JsonObject.from({'a': 1}); + expect(o.getTyped('missing'), isNull); + }); + + test('getTypedList wraps a single scalar value into a list', () { + final o = JsonObject.from({'aud': 'only-one'}); + expect(o.getTypedList('aud'), ['only-one']); + }); + + test('getTypedList returns each element of a list', () { + final o = JsonObject.from({ + 'aud': ['a', 'b', 'c'] + }); + expect(o.getTypedList('aud'), ['a', 'b', 'c']); + }); + + test('getTypedList returns null for absent keys', () { + final o = JsonObject.from({'a': 1}); + expect(o.getTypedList('missing'), isNull); + }); + }); + + group('safeUnion', () { + test('merges disjoint maps', () { + expect( + safeUnion([ + {'a': 1}, + {'b': 2}, + null, + ]), + {'a': 1, 'b': 2}, + ); + }); + + test('allows duplicate keys with equal values', () { + expect( + safeUnion([ + {'a': 1}, + {'a': 1, 'b': 2}, + ]), + {'a': 1, 'b': 2}, + ); + }); + + test('throws on conflicting duplicate keys', () { + expect( + () => safeUnion([ + {'a': 1}, + {'a': 2}, + ]), + throwsA(isA()), + ); + }); + }); + + group('commonUnion', () { + test('returns empty for an empty iterable', () { + expect(commonUnion([]), isEmpty); + }); + + test('keeps only the parameters common (and equal) to all maps', () { + expect( + commonUnion([ + {'a': 1, 'b': 2, 'c': 3}, + {'a': 1, 'b': 9, 'c': 3}, + {'a': 1, 'c': 3}, + ]), + {'a': 1, 'c': 3}, + ); + }); + + test('returns empty when nothing is shared', () { + expect( + commonUnion([ + {'a': 1}, + {'b': 2}, + ]), + isEmpty, + ); + }); + }); + + group('encodeBigInt', () { + test('encodes a positive BigInt to unpadded base64url', () { + // 0x010203 -> bytes [1,2,3] + final encoded = encodeBigInt(BigInt.parse('010203', radix: 16)); + final decoded = decodeBase64EncodedBytes(encoded); + expect(decoded, [1, 2, 3]); + }); + + test('encodes zero to an empty byte sequence', () { + expect(encodeBigInt(BigInt.zero), isEmpty); + }); + }); + + group('base64 helpers', () { + test('encode/decode round-trips arbitrary bytes without padding', () { + final data = [0, 1, 2, 250, 255]; + final encoded = encodeBase64EncodedBytes(data); + expect(encoded, isNot(contains('='))); + expect(decodeBase64EncodedBytes(encoded), data); + }); + }); +} diff --git a/packages/oidc/CHANGELOG.md b/packages/oidc/CHANGELOG.md index 53918e6e..5382eb31 100644 --- a/packages/oidc/CHANGELOG.md +++ b/packages/oidc/CHANGELOG.md @@ -1,3 +1,141 @@ +## 5.0.2 + + - **FIX**(ci): respect GitHub's 5-concurrent-macOS-jobs cap, un-shard macos. ([79e50312](https://github.com/Bdaya-Dev/oidc/commit/79e50312fe385d5cf27f1c304c2bf0ee989d2254)) + +## 5.0.1 + + - Update a dependency to the latest release. + +## 5.0.0 + +> Note: This release has breaking changes. + + - **FIX**(example): stop corrupting the web conformance POST through cors-proxy. ([4eea7cd8](https://github.com/Bdaya-Dev/oidc/commit/4eea7cd840c208582eaf873a0d34ce54e7e2a95e)) + - **FIX**(oidc): rethrow platform OidcException from getPlatformEndSessionResponse unwrapped. ([ab422b70](https://github.com/Bdaya-Dev/oidc/commit/ab422b70d38093ab4e85a56176df78908780ba71)) + - **FIX**: await futures returned inside try blocks (unawaited_return_in_try_block). ([452d6ee1](https://github.com/Bdaya-Dev/oidc/commit/452d6ee121674dee760f2d698f2a7d5565ad6600)) + - **FIX**(example): register the RP's true application_type with the test plan. ([1253d9be](https://github.com/Bdaya-Dev/oidc/commit/1253d9be404bd36ed5d8f83521ce74efb3ca73f1)) + - **FEAT**(oidc_core): mTLS transport, end-to-end alias routing and web guard ([#386](https://github.com/Bdaya-Dev/oidc/issues/386)). ([d3bd7213](https://github.com/Bdaya-Dev/oidc/commit/d3bd721399ddf579067791f12c536b2fb4022ad5)) + - **FEAT**(oidc_core): adopt and persist RFC 7592 credential rotation through the manager ([#385](https://github.com/Bdaya-Dev/oidc/issues/385)). ([0b6f57c1](https://github.com/Bdaya-Dev/oidc/commit/0b6f57c199bc3d8b76bf05845b5a0b13c1d9bcd8)) + - **BREAKING** **FIX**(oidc_core): reject a discovery document whose issuer does not match. ([fab65a06](https://github.com/Bdaya-Dev/oidc/commit/fab65a069d2b4bd4ac8d41fc78e6c194702f32b4)) + - **BREAKING** **FEAT**(oidc_default_store): bump flutter_secure_storage to ^11 ([#451](https://github.com/Bdaya-Dev/oidc/issues/451)). ([166f394e](https://github.com/Bdaya-Dev/oidc/commit/166f394ed7d937f1098a558b92f325fc52043700)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(example): keep the front-channel logout URI off Uri.base on native. ([be8a8968](https://github.com/Bdaya-Dev/oidc/commit/be8a896893bb78efcf7c9929368c2218a8d7e330)) + - **FIX**(example): serve the web RP at a conformant https origin. ([e62500b0](https://github.com/Bdaya-Dev/oidc/commit/e62500b06442ddd2bda20c934d4c1843aedaf3c3)) + - **FIX**(example): size the web per-test cap to the workload, not the reverse. ([932fb6ca](https://github.com/Bdaya-Dev/oidc/commit/932fb6caa9619af24ed37caf3ef31290b7a8eb57)) + - **FIX**(example): web loses Hybrid/Implicit to a timeout budget, not a fragment bug. ([624a502e](https://github.com/Bdaya-Dev/oidc/commit/624a502e04ceae7ac26ec05f3d523fe1a870bd7a)) + - **FIX**(example): gate Dynamic RP, which needs an RP-hosted request_uri. ([dda2caea](https://github.com/Bdaya-Dev/oidc/commit/dda2caea01f3a7e3bf89ac9c97a066aa72759bf1)) + - **FIX**(example): gate the logout profiles the suite cannot run, and count logouts. ([bfa48dbd](https://github.com/Bdaya-Dev/oidc/commit/bfa48dbdfeac73da6ab9a46840fe3ce86761287b)) + - **FIX**(example): let the suite supply Dynamic RP's module variant. ([05b0c847](https://github.com/Bdaya-Dev/oidc/commit/05b0c84730d198fd7795692d4e38165733190a06)) + - **FIX**(example): resolve WebFinger after setup, and dump the plan on a 500. ([3406f070](https://github.com/Bdaya-Dev/oidc/commit/3406f0705e73bc6afe8d4be553d483259d8ab0f8)) + - **FIX**(oidc_android): forward Map-valued raw intent extras instead of dropping them. ([7ffda6a7](https://github.com/Bdaya-Dev/oidc/commit/7ffda6a74f07aeaba3a077a1d879a615b3dfbe77)) + - **FEAT**(oidc_core): manager-level dynamic client registration (RFC 7591). ([fe4b4c4e](https://github.com/Bdaya-Dev/oidc/commit/fe4b4c4e30b1c97db06407dc32f7d4b1106e21ca)) + - **FEAT**(oidc_core): implement WebFinger issuer discovery (OIDC Discovery §2, RFC 7033). ([213657b8](https://github.com/Bdaya-Dev/oidc/commit/213657b8dad13d62a1c58d069c801cf26b995439)) + - **FEAT**(oidc_core,oidc_web_core): bound the interactive web flow with flowTimeoutSeconds. ([7282efa0](https://github.com/Bdaya-Dev/oidc/commit/7282efa09d406fa3eb012688fbbc091836d8ba2e)) + - **BREAKING** **FIX**(oidc_core): the REGISTERED token_endpoint_auth_method wins over the caller's. ([262bdfd4](https://github.com/Bdaya-Dev/oidc/commit/262bdfd4f0c1fac0f29c65be23bb3c061adb8310)) + - **BREAKING** **FIX**(oidc_android): report the options the Auth Tab path cannot apply. ([8b7226ce](https://github.com/Bdaya-Dev/oidc/commit/8b7226ced748e953e8b3ea95ba3c711d8976dd8d)) + +## 3.0.1 + + - Update a dependency to the latest release. + +## 3.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FIX**(oidc_android): restore the redirect receiver as a fallback capture path ([#418](https://github.com/Bdaya-Dev/oidc/issues/418)) ([#419](https://github.com/Bdaya-Dev/oidc/issues/419)). ([8df8bc61](https://github.com/Bdaya-Dev/oidc/commit/8df8bc617fb91347eac9f32fcffb7ec88f95328a)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FEAT**(oidc_web_core): status-driven redirect.html + app-ack wire ([#116](https://github.com/Bdaya-Dev/oidc/issues/116), [#227](https://github.com/Bdaya-Dev/oidc/issues/227), [#256](https://github.com/Bdaya-Dev/oidc/issues/256)) ([#397](https://github.com/Bdaya-Dev/oidc/issues/397)). ([3ea11929](https://github.com/Bdaya-Dev/oidc/commit/3ea119290f1007d62b9ecb8ffcaeb3a771f7df9f)) + - **FEAT**(oidc_core): emit OidcTokenRefreshFailedEvent on refresh failures; clamp the expiring-refire loop ([#392](https://github.com/Bdaya-Dev/oidc/issues/392)). ([0b581dff](https://github.com/Bdaya-Dev/oidc/commit/0b581dffcc9761d9ee112aa0df6856a610c10ac5)) + - **BREAKING** **FEAT**(oidc_core): cache-first init as the new default, discovery TTL cache, loaded-token validity controls ([#399](https://github.com/Bdaya-Dev/oidc/issues/399)). ([de3e2287](https://github.com/Bdaya-Dev/oidc/commit/de3e2287ec0649562e3df426f42a628168417378)) + +## 1.0.0 + +> Note: This release has breaking changes. + + - **FIX**: resolve all four library bugs; drive honest unit coverage to ~95% ([#368](https://github.com/Bdaya-Dev/oidc/issues/368)). ([c86bee17](https://github.com/Bdaya-Dev/oidc/commit/c86bee17189a0a70fee947c685e91a55062b1d35)) + - **FIX**(oidc_darwin): implement flowTimeoutSeconds for the Apple ASWebAuthenticationSession flow. ([482f0186](https://github.com/Bdaya-Dev/oidc/commit/482f0186b8cdb37d309118de187e1c8496555d9a)) + - **FIX**(example): add patrol RunnerUITests UI-testing target for iOS. ([21b740f6](https://github.com/Bdaya-Dev/oidc/commit/21b740f645be41a0792299660d2a96db26fb378a)) + - **FIX**(example): wrap patrol conformance placeholder with SharedValue.wrapApp. ([831df91c](https://github.com/Bdaya-Dev/oidc/commit/831df91c7f041a96af10bf1c80ef92f49d45a2c8)) + - **FIX**(example): use FlutterFragmentActivity for Auth Tab (ComponentActivity). ([e6e9be47](https://github.com/Bdaya-Dev/oidc/commit/e6e9be47683d80897a6dce6f92433408c89155f4)) + - **FIX**: pre-v1 correctness — certification claim, license, Android queries, honest native option docs. ([3b8ef447](https://github.com/Bdaya-Dev/oidc/commit/3b8ef447f2a1c0af68ec6711c77d435531fb827f)) + - **FIX**(spm,native): SwiftPM layout migration + conformance fixes; fix Android build. ([47f7bd25](https://github.com/Bdaya-Dev/oidc/commit/47f7bd25feb5d4b7a2e3db67165a44ecdf4dae29)) + - **FIX**(native): harden iOS threading, simplify Android redirect to one-line setup. ([a7553f32](https://github.com/Bdaya-Dev/oidc/commit/a7553f326c1d67ac2bd057b0864688d73df24661)) + - **FIX**: handle refresh responses without id_token. ([4af363be](https://github.com/Bdaya-Dev/oidc/commit/4af363bed630d18394b28af1664f334aca3df8d9)) + - **FEAT**(observability): native browser events via the existing OidcEvent stream (Phase 3). ([91d1f5bd](https://github.com/Bdaya-Dev/oidc/commit/91d1f5bdfa1526aec170474181ec71ad1bf38c59)) + - **BREAKING** **REFACTOR**: remove rxdart; adopt bdaya_shared_value ^5.0.0. ([0d65d7fd](https://github.com/Bdaya-Dev/oidc/commit/0d65d7fde062e2db7ffbdd31a47735c59954045a)) + - **BREAKING** **FEAT**(oidc_core): remove the strictJwtVerification fail-open opt-out. ([ee2146f9](https://github.com/Bdaya-Dev/oidc/commit/ee2146f9fa966c352a7c751673550bdcb5e7c0a5)) + - **BREAKING** **FEAT**(oidc_android): switch to Auth Tab only, remove Custom Tabs path. ([05bf0181](https://github.com/Bdaya-Dev/oidc/commit/05bf01811e299c472d49efb303fb657c939f0bd4)) + - **BREAKING** **FEAT**(oidc_android): add flowTimeoutSeconds to fix headless CI hang. ([01c844f5](https://github.com/Bdaya-Dev/oidc/commit/01c844f5bd98a3d983b9e50f9fa2192ed7013e50)) + - **BREAKING** **FEAT**: merge oidc_ios + oidc_macos into a unified oidc_darwin plugin. ([db73858e](https://github.com/Bdaya-Dev/oidc/commit/db73858e71b3b869326867b05b9d1ead3629acb9)) + - **BREAKING** **FEAT**(options): redesign native options API (v1 clean break, no AppAuth framing). ([a78954fe](https://github.com/Bdaya-Dev/oidc/commit/a78954feb4c4c6dfb0abc15f7e0a308be74d4e95)) + - **BREAKING** **FEAT**(oidc_macos): first-party ASWebAuthenticationSession; drop flutter_appauth. ([dc13f411](https://github.com/Bdaya-Dev/oidc/commit/dc13f411a3bfca4572a0f0e8fea2705365314d3c)) + - **BREAKING** **FEAT**: consolidate jose_plus, crypto_keys_plus, x509_plus into the workspace. ([3fffc6cd](https://github.com/Bdaya-Dev/oidc/commit/3fffc6cd51f2abb0ead643acc2ec4d5741fac8e5)) + - **BREAKING** **CHORE**: v1 dependency upgrade + drop the pigeon global-tool wrapper. ([45b62a3e](https://github.com/Bdaya-Dev/oidc/commit/45b62a3ef3f5b42cfb590111c9e37e144bbc11b0)) + +## 0.14.0+2 + + - **DOCS**: remove logo branding from screenshots. ([2acf65d3](https://github.com/Bdaya-Dev/oidc/commit/2acf65d34fb47c0449653a73373168df3deb1735)) + +## 0.14.0+1 + + - Update a dependency to the latest release. + +## 0.14.0 + +> Note: This release has breaking changes. + + - **FEAT**: improve offline mode integration tests with app startup handling. ([18a586c9](https://github.com/Bdaya-Dev/oidc/commit/18a586c933ddfdbf416510e95c63281d3401bedc)) + - **FEAT**: update dependencies and enhance offline mode test handling. ([a57d6490](https://github.com/Bdaya-Dev/oidc/commit/a57d64906bef50ac362bea16045ea6ba98f7b3f0)) + - **FEAT**: enhance offline mode handling in tests and user manager. ([e0cae79a](https://github.com/Bdaya-Dev/oidc/commit/e0cae79adf3fa8ccf18a530dbdda7cc339a75c0d)) + - **BREAKING** **FEAT**(oidc_default_store): use flutter_secure_storage. ([a9441511](https://github.com/Bdaya-Dev/oidc/commit/a9441511a9f27aa742cde2459b0654541b0aa356)) + - **BREAKING** **FEAT**: Add offline mode events and error handling. ([7479fd15](https://github.com/Bdaya-Dev/oidc/commit/7479fd15cf0029e9b865333709a98363b5119d64)) + +## 0.13.0 + +> Note: This release has breaking changes. + + - **FEAT**: add OIDC conformance test suite with API client and test runner. ([a651a7d8](https://github.com/Bdaya-Dev/oidc/commit/a651a7d814a424683d141fcc66156e5d17112baa)) + - **BREAKING** **FIX**: hasInit is set to true too early in init() of OidcUserManagerBase ([#275](https://github.com/Bdaya-Dev/oidc/issues/275)). ([d704aa5f](https://github.com/Bdaya-Dev/oidc/commit/d704aa5fe7449051831fc062919712a1c5075a13)) + - **BREAKING** **FIX**: migrate to simple_secure_storage ([#270](https://github.com/Bdaya-Dev/oidc/issues/270)). ([723560a7](https://github.com/Bdaya-Dev/oidc/commit/723560a7e7d212290205724d7af6799f217ab778)) + - **BREAKING** **FEAT**: Support WASM ([#253](https://github.com/Bdaya-Dev/oidc/issues/253)). ([8b2931ef](https://github.com/Bdaya-Dev/oidc/commit/8b2931ef64c7b25609db563e3d14bf37f5504922)) + +## 0.12.1+2 + + - **DOCS**: Add openid certification mark ([#240](https://github.com/Bdaya-Dev/oidc/issues/240)). ([89313199](https://github.com/Bdaya-Dev/oidc/commit/8931319937b9c263abae9ac873433dd6bd5fa637)) + +## 0.12.1+1 + + - Update a dependency to the latest release. + +## 0.12.1 + + - **FEAT**: update changelogs to reflect breaking changes and new features for multiple OIDC platforms. ([4caca121](https://github.com/Bdaya-Dev/oidc/commit/4caca121f63fd21d71aaffa2730b092fc26a7da5)) + +## 0.12.0 + - **TESTS**: Added integration tests to run the official oidc conformance suite, this pumps our test coverage from 22% to almost 45%, effectively doubling it! + - Tests are run on ALL platforms (linux, macos, windows, android, ios, web). + - Tests check the authorization code flow only for now. + - **BREAKING** **FEAT**: Support for multiple `OidcUserManager` instances, by adding the `id` field to differentiate them. + - [See docs entry](https://bdaya-dev.github.io/oidc/oidc-usage/#constructing-multiple-user-managers) + - **FEAT**: Updated the example app to use the new `OidcUserManager.id`, with the ability to add your own custom managers in the UI. + + > [!IMPORTANT] + > We have also officially submitted our package to the openid foundation for [certification](https://github.com/Bdaya-Dev/oidc/issues/11). + +## 0.11.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FEAT**: support hooks ([#228](https://github.com/Bdaya-Dev/oidc/issues/228)). ([f2d9d9c6](https://github.com/Bdaya-Dev/oidc/commit/f2d9d9c692e0cf0baac36f186be337ff62e142df)) + ## 0.10.0 > Note: This release has breaking changes. diff --git a/packages/oidc/README.md b/packages/oidc/README.md index 8b8f5826..1cdf1b74 100644 --- a/packages/oidc/README.md +++ b/packages/oidc/README.md @@ -1,4 +1,5 @@ # oidc +[![openid certification](http://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg)](https://openid.net/developers/certified-openid-connect-implementations/) [![Pub Version][pub_badge]][pub_link] [![last commit][github_last_commit_image]][github_link] @@ -72,14 +73,15 @@ await manager.logout(); - 🚀 **Easy to use**: you mainly need to concern yourself with the `OidcUserManager` class, which is very well documented and has a simple interface. ### 📜 Conformance -- while we are **NOT** openid certified *yet*, this package was tested against [multiple conformance profiles](https://github.com/Bdaya-Dev/oidc/issues/11). +- this package is an **[OpenID Connect Certified](https://openid.net/certification/) Relying Party**, and is additionally tested against [multiple conformance profiles](https://github.com/Bdaya-Dev/oidc/issues/11). ### Implemented specs - [OpenId Connect Core 1.0][oidc_core_spec_link]. - - no hybrid flow support yet. + - authorization code, hybrid (`loginHybridFlow`), and implicit flows. - [OpenId Connect Discovery][discovery_spec_link]. - - no webfinger support yet. + - §4 Provider Configuration: the `.well-known/openid-configuration` document (also RFC 8414 `.well-known/oauth-authorization-server`), with `issuer` validation and RFC 8414 `signed_metadata` verification. + - §2 OpenID Provider Issuer Discovery: [WebFinger](https://www.rfc-editor.org/rfc/rfc7033) via `OidcEndpoints.getIssuerViaWebFinger('joe@example.com')`, including §2.1 identifier normalization (`OidcUtils.normalizeWebFingerIdentifier`), HTTPS-only transport with https-only redirects, and client-side `rel` filtering. Resolve the identifier to an issuer first, then pass that issuer to `OidcUserManager` — the manager does not run WebFinger on your behalf. Internationalized host names are not supported: convert them to their punycode A-label (RFC 5891) before calling, or normalization throws. - [RP Initiated logout][rp_logout_link]. - [Front-Channel Logout][frontchannel_logout_link]. - [Authorization code grant][auth_code_link] with [PKCE][pkce_link]. @@ -87,12 +89,31 @@ await manager.logout(); - Automatic [Refresh Token](https://oauth.net/2/grant-types/refresh-token/) rotation. - [OAuth 2.0 For Native Apps](https://datatracker.ietf.org/doc/html/rfc8252) - [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) -- [Session Management](https://openid.net/specs/openid-connect-session-1_0.html) (Web only) - - -### WIP Specs - -- [Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html) +- [Session Management](https://openid.net/specs/openid-connect-session-1_0.html) (Web only). + - **Web only, by design — not a gap that will be filled on native platforms.** The RP-side check embeds `check_session_iframe` in a frame the RP controls, posts `"$clientId $sessionState"` into it, and reads back the OP's answer; that answer is computed from the OP session cookie held by whichever browser is loading the iframe. [RFC 8252 §8.12](https://datatracker.ietf.org/doc/html/rfc8252#section-8.12) requires native apps (Android, iOS, macOS, Linux, Windows) to perform login in an **external** user-agent, not an embedded WebView, so there is no app-controlled browser surface left to host that frame in. Even granting a SEPARATE, app-controlled WebView purely for session polling would not help: [OpenID Connect Session Management 1.0 §3.2](https://openid.net/specs/openid-connect-session-1_0.html#RPiframe) ties the answer to the cookie in the user-agent that did the §8.12 login, which a second, freshly-created WebView never has — it could only ever answer "changed", which is not a meaningful check. + - On native platforms, detect a session that ended at the OP through other signals instead: `OidcTokenRefreshFailedEvent` (a terminal automatic-refresh failure, e.g. `invalid_grant`, surfaces the next time the token is refreshed), `OidcUserInfoFailedEvent` (a `401` from the userinfo endpoint, surfaces the next time it's called) — both on `manager.events()` — [Front-Channel Logout](#implemented-specs) or Back-Channel Logout if your OP can push the logout to the app, or [OpenID Connect Native SSO for Mobile Apps](https://openid.net/specs/openid-connect-native-sso-1_0.html) if multiple apps from the same vendor need to share a signed-out state. None of these are instant like `check_session_iframe` polling — they fire on the next token use, not the moment the OP session ends. +- [Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html) ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)). + - Set `OidcUserManagerSettings.dynamicClientRegistration` and `init()` registers the client at the discovery document's `registration_endpoint` (optionally with an initial access token), then runs as the ISSUED credentials — the `clientCredentials` you pass to the constructor become a pre-registration seed. The issued `token_endpoint_auth_method` is what every back-channel call then uses, and it cannot be overridden — OpenID Connect Core §3.1.3.1 and §12.1 both require a client to "authenticate to the Token Endpoint using the authentication method registered for its `client_id`". `preferredTokenEndpointAuthMethod` decides only when the OP states none, where RFC 7591 §2 would otherwise pick `client_secret_basic` unaided. + - **The manager registers once per issuer and then leaves it alone.** One immutable record is persisted under one key; every later launch restores it with **zero** network calls. That record is re-issued only when the request this build would send no longer matches the one it was issued for, or when its `client_secret` has expired. Nothing else in the library ever re-derives, rotates or replaces the client identity on its own — the only other way it moves is an RFC 7592 call **you** make through the manager (below). See the non-goals further down for what is deliberately left to you. + - **RFC 7592 management of the manager's own client, with rotation.** `manager.updateClientRegistration(edit: (req) => req..clientName = 'New name')` sends the RFC 7592 §2.2 `PUT` — starting from every member the OP previously returned (§2.2: values replace, not augment), with your edit applied — and `manager.readClientRegistration()` sends the §2.1 `GET`. Both **adopt the response as the manager's identity and persist it**: §2.2 says that when the response carries a new `client_secret` and/or `registration_access_token` "the client MUST immediately discard its previous" ones, so the next back-channel call already presents the rotated secret and the next launch restores it instead of the retired one. Members the response leaves out (`registration_access_token`, `registration_client_uri`, and `client_secret` unless the client became public) are carried over rather than dropped. The record keeps the fingerprint it was *issued* for, so an update never triggers a re-registration. A response for a different `client_id` is refused, and calls are serialized so a second call presents the token the first one rotated in. If the store cannot take the write, the manager still switches to the rotated credentials (the old ones are dead) and throws a typed `OidcException`. Calling `OidcEndpoints.updateClientConfiguration` directly still works, but the manager and the store never see what it rotates. + - **Where the registration is stored, and why there is no new `OidcStoreNamespace`.** The record (including `registration_access_token` / `registration_client_uri`) lives in `OidcStoreNamespace.secureTokens` under the key `client_registration.`, so it gets the same secure-storage guarantees as the tokens. A dedicated enum value was considered and rejected: `OidcStoreNamespace` is an enum that every third-party `OidcStore` switches over or maps to a storage location, so adding a value is a breaking change for those implementations, and the new namespace would have needed exactly the guarantees `secureTokens` already has. A key prefix in `secureTokens` is non-breaking, and `forgetUser()` spares that prefix because the registration identifies the app instance, not the user. + - **Registered out of band instead?** If you call `OidcEndpoints.registerClient` yourself (or a backend hands you the response), leave `dynamicClientRegistration` unset and pass `clientCredentials: OidcClientAuthentication.fromRegistrationResponse(response)` to the manager's constructor. Keeping that response, and its RFC 7592 credentials, is then up to you. + - **Public clients are supported.** RFC 7591 §3.2.1 makes `client_secret` OPTIONAL, so an OP that issues a `client_id` alone (and states no `token_endpoint_auth_method`) yields a public client authenticating with `none` — §2's `client_secret_basic` default only applies when a secret WAS issued. + - The manager fills the metadata it can derive from your settings when your `buildRequest` leaves it null: `redirect_uris`, `scope`, `post_logout_redirect_uris`, `grant_types` / `response_types` (which default to `authorization_code` + `refresh_token` / `code`, because RFC 7591 §2 would otherwise register the client for `authorization_code` alone and break the automatic refresh), and `application_type`. **Flows you opt into must be declared:** add `OidcConstants_GrantType.deviceCode` for the device-code flow, or `implicit` plus the matching `responseTypes` for implicit/hybrid. + - **`application_type` is derived from your redirect uris, not from the platform.** OpenID Connect Registration §2 states it as a client-side rule: a `native` client "MUST only register `redirect_uris` using custom URI schemes or loopback URLs using the `http` scheme", and a `web` client MUST use `https` and MUST NOT use `localhost`. So an Android or iOS app whose redirect is an App Link / Universal Link (`https://app.example.com/callback`) registers as **`web`** — declaring it `native` because it runs on a phone authors a request a conformant OP must reject. Set `applicationType` in your `buildRequest` to override. + - RFC 7591 §3.2.1 lets the OP substitute its own values; the `scope`, `redirect_uris` and `post_logout_redirect_uris` it returns — not the ones that were requested — are what later authorization / token / end-session requests use. The `client_id` and `token_endpoint_auth_method` from the response likewise become the credentials every back-channel call presents. + - **Loopback redirects are compared without their port** ([RFC 8252 §7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3): "the authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs"). A desktop app whose listener binds an ephemeral port therefore matches its stored registration on every launch instead of minting a new OP-side client each run, and its authorization request keeps the **live** port even when the OP registered a different one. Only loopback `http` is normalized; every other uri, including `https`, compares byte-for-byte (RFC 3986 §6.2.1). + - **Web is always registered as a PUBLIC client.** A browser app has nowhere to keep a secret ([OAuth 2.0 for Browser-Based Apps §6.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)): the registration lives in `OidcStoreNamespace.secureTokens`, which on web is browser storage (`localStorage` via `shared_preferences` when no `FlutterSecureStorage` is passed to `OidcDefaultStore`), readable by any injected script. So on web the manager asks for `token_endpoint_auth_method: "none"` unless your `buildRequest` sets one — RFC 7591 §2 would otherwise default the client to the confidential `client_secret_basic` — and **refuses** a response that issues a `client_secret` anyway, with a typed `OidcException` thrown *before* anything is written. A provider that insists on issuing one is telling you the client belongs on a backend. The same refusal applies to a persisted registration carrying a secret, so a client registered on another platform is never replayed in a browser. + - The record is persisted per-issuer in the secure store and re-used on every later launch — including across `forgetUser()`, since it is app-instance identity, not user identity. Staleness is measured against the request that was actually registered (after the defaults above are applied), not against your settings alone, so a change made *inside* `buildRequest` counts too: the fingerprint covers `redirect_uris`, `post_logout_redirect_uris`, `scope`, `grant_types`, `response_types` and `application_type`. It deliberately ignores `software_statement`, `jwks` and every purely descriptive member (`client_name`, `logo_uri`, …) — those are re-signed or edited on their own schedule, and RFC 7592 §2.2 update is their remedy, not orphaning the OP-side client for a fresh one. + - **Nothing is ever deleted to make room for something that does not exist yet.** There is exactly one write of the registration key and exactly one removal — `forgetClientRegistration()`, which you call. A record that cannot be read (a locked keychain, an Android key invalidated by a biometric re-enrolment), cannot be parsed, or cannot be converted into credentials is treated as a **cache miss** and left exactly where it is; the replacement overwrites it in place once it has been issued **and** persisted. So an app update that changes the fingerprint and then launches offline keeps running as the previously-issued client — the `client_id` and the RFC 7592 `registration_access_token` survive — instead of being stranded with nothing. The write comes before the in-memory swap for the same reason: RFC 7591 registration is not idempotent, so a registration that could not be persisted is reported as a typed `OidcException` rather than silently minting another OP-side client on every launch. + - **Non-goals**, each with the clause that makes it optional and the call you make instead. All of them are reachable today: `updateClientRegistration()` / `readClientRegistration()` on the manager, the full RFC 7592 client-management surface as `OidcEndpoints.registerClient` / `readClientConfiguration` / `updateClientConfiguration` / `deleteClientConfiguration`, and `OidcUserManagerBase.clientRegistration`, which hands you the `registration_client_uri` and `registration_access_token` they need. + - **No automatic `client_secret` rotation.** RFC 7592 App. A.1: "the authorization server decides the frequency of the credential rotation and not the client" — the §2.1 read is an OP-driven affordance, not a client obligation, and an OP that conformantly returns the registration verbatim leaves a client-driven rotation loop with nothing to make progress on. Against an OP that *does* rotate on a schedule (Zitadel, Keycloak, Okta can be configured to) the session breaks at expiry with a typed `OidcException`; write `catch → manager.readClientRegistration()` (which persists the rotated credentials), or `catch → forgetClientRegistration() → build a new manager and init()`. The cost of the latter is a re-login and one orphaned OP-side client. + - **No automatic re-registration when the provider disowns the client.** RFC 6749 §5.2 makes `invalid_client` three-way ambiguous ("unknown client, no client authentication included, or unsupported authentication method") and OpenID Connect Registration §4.4 forbids the 404 that would disambiguate it, so there is no reliable trigger. The rejection is surfaced to you untouched — nothing is re-registered, nothing is deleted — and the same `forgetClientRegistration()` handler applies. 0 of 7 mature RP libraries automate this. + - **No automatic RFC 7592 `PUT`/`DELETE` from the manager, and no orphan tracking or reaping.** RFC 7591 §5 assigns cleanup of registered-but-unused clients to the authorization server. Orphaned OP-side clients therefore accumulate untracked: one per cache miss, fingerprint change, expired-secret restore, storage fault, cross-manager race and web page load. Nothing counts, caps or reaps them, so **an OP with a per-tenant client quota will eventually reject registrations** with no hint that quota is the cause. Call `OidcEndpoints.deleteClientConfiguration` (§2.3) before `forgetClientRegistration()` if you want the OP-side client retired, and `manager.updateClientRegistration()` (§2.2) to edit one in place. + - **No cross-manager, cross-tab or cross-process coordination.** `OidcStore` has no compare-and-set, so nothing can be promised: two managers, two browser tabs, or two `OidcStore` objects over one backing store each mint a client and the last writer wins the key. Give managers that are meant to be independent distinct `managerId`s. + - **No detection of a `client_id` the OP rotated on its own**, and no recovery from a POST whose response was lost in transit (that mints a client no design on any transport can ever name). + - **`private_key_jwt` registrations are not automated**, because the signing key cannot be derived from a registration response — build `OidcClientAuthentication.privateKeyJwtGenerated` yourself and register the client out of band. Mutual-TLS registrations (`tls_client_auth` / `self_signed_tls_client_auth`, RFC 8705) are derived automatically, because they need only the `client_id`. The certificate comes from the cert-bearing `httpClient` (`OidcMtls.createHttpClient`), and on web the derived mTLS credentials are rejected with an `UnsupportedError`. + - Why the non-goals are stated as non-goals rather than gaps: every one of them needs *durable control state* — a retry budget, an attempt counter, a rejection verdict, a generation number, a ledger of minted clients — and every such measure can be destroyed or reset by the very action it gates. There is none here. The store holds exactly one immutable record per issuer, containing only the OP's response and a digest of the request it was issued for, and `packages/oidc_core/test/dcr_invariants_test.dart` counts that mechanically: one write call site, one removal call site, one read call site, in three different methods, with no read-modify-write anywhere. --- diff --git a/packages/oidc/analysis_options.yaml b/packages/oidc/analysis_options.yaml index 79780ac3..3c1e20a9 100644 --- a/packages/oidc/analysis_options.yaml +++ b/packages/oidc/analysis_options.yaml @@ -10,3 +10,5 @@ linter: analyzer: errors: invalid_use_of_internal_member: ignore + exclude: + - build/** diff --git a/packages/oidc/dart_test.yaml b/packages/oidc/dart_test.yaml deleted file mode 100644 index 7bab314c..00000000 --- a/packages/oidc/dart_test.yaml +++ /dev/null @@ -1,11 +0,0 @@ -override_platforms: - chrome: - settings: - headless: true - firefox: - settings: - # headless argument has to be set explicitly for non-chrome browsers - arguments: --headless - executable: - # https://github.com/dart-lang/test/pull/2195 - mac_os: '/Applications/Firefox.app/Contents/MacOS/firefox' \ No newline at end of file diff --git a/packages/oidc/example/.ci/scripts/set_default_linux_apps.sh b/packages/oidc/example/.ci/scripts/set_default_linux_apps.sh new file mode 100644 index 00000000..48f5c0f5 --- /dev/null +++ b/packages/oidc/example/.ci/scripts/set_default_linux_apps.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +# This script is a modified copy of https://github.com/flutter/packages/blob/main/.ci/scripts/set_default_linux_apps.sh +set -e + +# Set up a .desktop file pointing to the CIPD-installed Chrome. +readonly LOCAL_DESKTOP_FILE_DIR=~/.local/share/applications +readonly CHROME_EXECUTABLE="/usr/bin/google-chrome" +mkdir -p "${LOCAL_DESKTOP_FILE_DIR}" +readonly DESKTOP_FILE_NAME=cipd-chrome.desktop +readonly CIPD_CHROME_DESKTOP_FILE="${LOCAL_DESKTOP_FILE_DIR}/${DESKTOP_FILE_NAME}" +# Every authorization/end-session request launches its own headless Chrome, +# and a headless Chrome given a URL never exits on its own. The conformance +# run opens well over a hundred of them; left alive they exhaust the runner, +# which then dies with "received a shutdown signal" (exit 143). A redirect +# lands within seconds and the client's flow timeout is 30s, so 60s is ample. +readonly BROWSER_LIFETIME_SECONDS=60 +cat << EOF > "${CIPD_CHROME_DESKTOP_FILE}" +[Desktop Entry] +Version=1.0 +Name=Google Chrome +GenericName=Web Browser +Comment=Access the Internet +Exec=/usr/bin/timeout -k 5 ${BROWSER_LIFETIME_SECONDS} ${CHROME_EXECUTABLE} --headless %U +StartupNotify=true +Terminal=false +Icon=google-chrome +Type=Application +Categories=Network;WebBrowser; +MimeType=application/pdf;application/rdf+xml;application/rss+xml;application/xhtml+xml;application/xhtml_xml;application/xml;image/gif;image/jpeg;image/png;image/webp;text/html;text/xml;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/file; +EOF + +# Enable xdg-* commands to work correctly. +export DESKTOP_SESSION=gnome + +# Set Chrome as the default handler for http, https, and file, for url_launcher +# tests that expect handlers for those schemes. +xdg-mime default "${DESKTOP_FILE_NAME}" inode/directory +xdg-settings set default-web-browser "${DESKTOP_FILE_NAME}" \ No newline at end of file diff --git a/packages/oidc/example/.gitignore b/packages/oidc/example/.gitignore index d9d1bbcd..18fd374d 100644 --- a/packages/oidc/example/.gitignore +++ b/packages/oidc/example/.gitignore @@ -44,3 +44,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# TLS material generated by the web conformance job (never committed). +.ci/tls/ diff --git a/packages/oidc/example/analysis_options.yaml b/packages/oidc/example/analysis_options.yaml index 92cfb3c9..333198f5 100644 --- a/packages/oidc/example/analysis_options.yaml +++ b/packages/oidc/example/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:very_good_analysis/analysis_options.5.0.0.yaml linter: rules: diff --git a/packages/oidc/example/android/.gitignore b/packages/oidc/example/android/.gitignore index bd4ebd72..835d8b42 100644 --- a/packages/oidc/example/android/.gitignore +++ b/packages/oidc/example/android/.gitignore @@ -9,6 +9,7 @@ GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties +.kotlin/ **/*.keystore **/*.jks .cxx/ \ No newline at end of file diff --git a/packages/oidc/example/android/app/build.gradle b/packages/oidc/example/android/app/build.gradle index 300245ec..222c84c6 100644 --- a/packages/oidc/example/android/app/build.gradle +++ b/packages/oidc/example/android/app/build.gradle @@ -26,24 +26,36 @@ android { namespace 'com.bdayadev.oidc.example' compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } defaultConfig { applicationId "com.bdayadev.oidc.example" + // minSdk 24 to support flutter_secure_storage ^11 minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - manifestPlaceholders += [ - 'appAuthRedirectScheme': 'com.bdayadev.oidc.example' - ] + // MainActivityTest casts InstrumentationRegistry.getInstrumentation() to + // PatrolJUnitRunner; the stock runner would throw ClassCastException. + testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments clearPackageData: "true" + // Scheme of this app's redirect_uri, see getPlatformRedirectUri() in + // integration_test/helpers.dart. Supplies the plugin's redirect receiver, + // which is the capture path on browsers without Auth Tab support. + manifestPlaceholders += ['oidcRedirectScheme': 'com.bdayadev.oidc.example'] + } + + // PatrolJUnitRunner detects the discovery pass via the orchestrator's + // "listTestsForOrchestrator" argument. Without ANDROIDX_TEST_ORCHESTRATOR that + // argument is never set, so Dart test names never become JUnit parameters. + testOptions { + execution "ANDROIDX_TEST_ORCHESTRATOR" } buildTypes { @@ -55,6 +67,12 @@ android { } } +dependencies { + // 1.5.1 or newer: 1.5.0 cannot handle whitespace in test names, and this + // suite uses names such as "OIDC Conformance Test". + androidTestUtil "androidx.test:orchestrator:1.5.1" +} + flutter { source '../..' -} \ No newline at end of file +} diff --git a/packages/oidc/example/android/app/src/androidTest/java/com/bdayadev/oidc/example/MainActivityTest.java b/packages/oidc/example/android/app/src/androidTest/java/com/bdayadev/oidc/example/MainActivityTest.java new file mode 100644 index 00000000..ca49c9d3 --- /dev/null +++ b/packages/oidc/example/android/app/src/androidTest/java/com/bdayadev/oidc/example/MainActivityTest.java @@ -0,0 +1,50 @@ +package com.bdayadev.oidc.example; + +import androidx.test.platform.app.InstrumentationRegistry; +import io.flutter.embedding.android.FlutterFragmentActivity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +/** + * Android instrumentation entrypoint for the Patrol suite, and the counterpart + * of {@code ios/RunnerUITests/RunnerUITests.m}. + * + *

Patrol's CLI does not generate this file, so it has to live in the repo. + * Without it the app has no {@code androidTest} source set, + * {@code connectedDebugAndroidTest} enumerates zero JUnit classes, and the Dart + * {@code listDartTests()} RPC is never reached: the run then reports success + * having executed nothing. + * + *

{@link FlutterFragmentActivity} is passed because this example declares no + * custom MainActivity and the manifest launches the Flutter activity directly. + * It is only a fallback: {@code PatrolJUnitRunner.setUp} prefers + * {@code getLaunchIntentForPackage}, and the manifest declares a LAUNCHER + * intent-filter. + */ +@RunWith(Parameterized.class) +public class MainActivityTest { + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(FlutterFragmentActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + private final String dartTestName; + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} diff --git a/packages/oidc/example/android/app/src/main/AndroidManifest.xml b/packages/oidc/example/android/app/src/main/AndroidManifest.xml index ab39e632..dfc5bf3e 100644 --- a/packages/oidc/example/android/app/src/main/AndroidManifest.xml +++ b/packages/oidc/example/android/app/src/main/AndroidManifest.xml @@ -6,10 +6,11 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:fullBackupContent="@xml/backup_rules" - android:dataExtractionRules="@xml/data_extraction_rules"> + android:dataExtractionRules="@xml/data_extraction_rules" + > - + \ No newline at end of file diff --git a/packages/oidc/example/android/app/src/main/res/xml/data_extraction_rules.xml b/packages/oidc/example/android/app/src/main/res/xml/data_extraction_rules.xml index b7d33409..8d1a5456 100644 --- a/packages/oidc/example/android/app/src/main/res/xml/data_extraction_rules.xml +++ b/packages/oidc/example/android/app/src/main/res/xml/data_extraction_rules.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/packages/oidc/example/android/gradle.properties b/packages/oidc/example/android/gradle.properties index f3dfe334..4147ba38 100644 --- a/packages/oidc/example/android/gradle.properties +++ b/packages/oidc/example/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/packages/oidc/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/oidc/example/android/gradle/wrapper/gradle-wrapper.properties index 8464a104..4659a526 100644 --- a/packages/oidc/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/oidc/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip \ No newline at end of file diff --git a/packages/oidc/example/android/settings.gradle b/packages/oidc/example/android/settings.gradle index a2e945cf..9a070fef 100644 --- a/packages/oidc/example/android/settings.gradle +++ b/packages/oidc/example/android/settings.gradle @@ -19,8 +19,8 @@ pluginManagement { // See https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure for more info. plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.0" apply false + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false id "com.google.cloud.artifactregistry.gradle-plugin" version "2.2.1" } diff --git a/packages/oidc/example/devtools_options.yaml b/packages/oidc/example/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/packages/oidc/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/packages/oidc/example/integration_test/app_test.dart b/packages/oidc/example/integration_test/app_test.dart index ba2b53ef..f0e920d8 100644 --- a/packages/oidc/example/integration_test/app_test.dart +++ b/packages/oidc/example/integration_test/app_test.dart @@ -1,30 +1,167 @@ +// integration_test entrypoint (testWidgets) — used by the linux/windows CI +// jobs. It launches the real example app via its main()/runApp, which is fine +// under IntegrationTestWidgetsFlutterBinding. android/iOS/macOS/web run the +// same flow via Patrol (patrol_test/app_test.dart). Logic lives in +// shared_e2e.dart. + import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:oidc_example/app_state.dart' as app_state; import 'package:oidc_example/main.dart' as example; +import 'shared_e2e.dart'; + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + ensureLoggingConfigured(); group('E2E', () { - testWidgets('manager initializes correctly', (tester) async { - example.main(); - expect(app_state.currentManager.didInit, false); - await tester.pumpAndSettle(); - expect(app_state.currentManager.didInit, true); - }); - - // testWidgets( - // 'login works', - // (tester) async { - // example.main(); - // await tester.pumpAndSettle(); - // // final loginFuture = - // // app_state.currentManager.loginAuthorizationCodeFlow(); - // // print('waiting for login future...'); - - // // await loginFuture; - // }, - // ); + if (oidcConformanceToken.isEmpty) { + testWidgets('Simple manager initializes correctly', (tester) async { + await runManagerSmokeTest(() async { + example.main(); + await tester.pumpAndSettle(); + }); + }); + } else { + testWidgets('OIDC Conformance: Basic RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }); + }); + + // The four logout profiles, discovered from the suite's own api/plan/available + // rather than guessed. Note the shape: they do NOT use the + // `-certification-test-plan` suffix the other profiles use, which is why no + // amount of searching produced them and why every guessed id would have + // 400ed. Each is pinned to the `-rp-basic` variant, matching the profile + // this library is certified against. + testWidgets('OIDC Conformance: RP-Initiated Logout', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-rp-initiated-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + testWidgets('OIDC Conformance: Front-Channel Logout', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-front-channel-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + testWidgets('OIDC Conformance: Back-Channel Logout', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-back-channel-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + testWidgets('OIDC Conformance: Session Management', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-rp-session-management-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + // Form Post and 3rd-party-init, from the suite's published list. Variant + // rules are unknown for these; they omit clientAuthType, matching the + // plans they most resemble. A wrong choice is named by the plan-creation + // diagnostic and pinned in api.dart, not rediscovered. + testWidgets('OIDC Conformance: Form Post Basic RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }, planName: 'oidcc-client-formpost-basic-certification-test-plan'); + }); + + testWidgets('OIDC Conformance: Form Post Hybrid RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }, planName: 'oidcc-client-formpost-hybrid-certification-test-plan'); + }); + + testWidgets('OIDC Conformance: Form Post Implicit RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }, planName: 'oidcc-client-formpost-implicit-certification-test-plan'); + }); + + testWidgets('OIDC Conformance: 3rd Party-Init Login RP', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-test-3rd-party-init-login-test-plan', + extraPlanVariant: {'response_type': 'code'}, + clientAuthType: 'client_secret_basic', + ); + }); + + // Dynamic RP: the client registers itself at the OP rather than being + // pre-provisioned, so this is the one plan that does NOT take + // static_client. `dynamic_client` is the documented counterpart already + // named in the harness's own variant comment, not a guess at a new value. + testWidgets('OIDC Conformance: Dynamic RP', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-dynamic-certification-test-plan', + clientRegistration: null, + clientAuthType: 'client_secret_basic', + requestType: null, + ); + }); + + // See the Patrol harness for why each plan is a separate case. + testWidgets('OIDC Conformance: Hybrid RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }, planName: 'oidcc-client-hybrid-certification-test-plan'); + }); + + testWidgets('OIDC Conformance: Implicit RP', (tester) async { + await runOidcConformanceTest(() async { + example.main(); + await tester.pumpAndSettle(); + }, planName: 'oidcc-client-implicit-certification-test-plan'); + }); + + // See the Patrol harness for why Config RP is a separate case. + testWidgets('OIDC Conformance: Config RP', (tester) async { + await runOidcConformanceTest( + () async { + example.main(); + await tester.pumpAndSettle(); + }, + planName: 'oidcc-client-config-certification-test-plan', + // The discovery module rejects the plan outright without this; the + // Basic plan defaults it, this one does not. + clientAuthType: 'client_secret_basic', + ); + }); + } }); } diff --git a/packages/oidc/example/integration_test/conformance/api.dart b/packages/oidc/example/integration_test/conformance/api.dart new file mode 100644 index 00000000..ba0176be --- /dev/null +++ b/packages/oidc/example/integration_test/conformance/api.dart @@ -0,0 +1,1088 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +/// Variant dimensions a plan's modules require to be stated outright. +/// +/// The suite defaults some dimensions and demands others, and which is which +/// cannot be read off the plan name. Getting it wrong is not a test failure: +/// it is an HTTP 400 at plan creation, from a live third-party server, so the +/// mistake costs a full CI round trip to discover. `client_auth_type` is +/// recorded here because the config plan's discovery module refuses the whole +/// plan without it, while the basic plan resolves it to `client_secret_basic` +/// on its own -- which is exactly why the omission went unnoticed on the only +/// plan that had ever been run. +/// +/// Add a plan here when the suite rejects it for a missing dimension. The +/// entry turns that network round trip into an immediate local error. +const _requiredExtraVariants = >{ + 'oidcc-client-config-certification-test-plan': ['client_auth_type'], + // All four logout plans demand it too, like config and unlike hybrid and + // implicit, which reject it. Five plans, three answers, one dimension. + 'oidcc-client-rp-initiated-logout-rp-basic': ['client_auth_type'], + 'oidcc-client-front-channel-logout-rp-basic': ['client_auth_type'], + 'oidcc-client-back-channel-logout-rp-basic': ['client_auth_type'], + 'oidcc-client-rp-session-management-rp-basic': ['client_auth_type'], + 'oidcc-client-test-3rd-party-init-login-test-plan': [ + 'client_auth_type', + 'response_type', + ], + // Dynamic RP is the fourth distinct combination for the same three + // dimensions: FORBIDS request_type and client_registration (below), and + // REQUIRES client_auth_type. Its webfinger module said so: + // TestModule 'oidcc-client-test-discovery-webfinger-acct' requires a + // value for variant 'client_auth_type' + 'oidcc-client-dynamic-certification-test-plan': ['client_auth_type'], +}; + +/// Variant dimensions a plan sets ITSELF, and rejects the caller for setting. +/// +/// The exact inverse of [_requiredExtraVariants], for the same dimension: +/// +/// Variant 'client_auth_type' has been set by user, but test plan already +/// sets this variant for module 'oidcc-client-test' +/// +/// So `client_auth_type` is optional on the basic plan, mandatory on the +/// config plan, and forbidden on these two. Three rules for one dimension, +/// none of them derivable from the plan name -- each cost an HTTP 400 to +/// learn, which is precisely why both directions are recorded here instead of +/// being rediscovered from CI. +const _forbiddenExtraVariants = >{ + 'oidcc-client-hybrid-certification-test-plan': ['client_auth_type'], + 'oidcc-client-implicit-certification-test-plan': ['client_auth_type'], + // A SECOND dimension with per-plan rules. Dynamic RP sets request_type + // itself, so the plain_http_request every other plan needs is rejected here. + // request_type is therefore nullable below: "always send it" was an + // assumption, not a requirement. + // Dynamic RP sets BOTH itself. Passing client_registration: dynamic_client + // looked like the obvious counterpart to static_client and was rejected -- + // the plan is "dynamic" precisely because it owns that dimension. + 'oidcc-client-dynamic-certification-test-plan': [ + 'request_type', + 'client_registration', + ], +}; + +/// Extra variant dimensions a plan needs at the MODULE endpoint +/// (`api/runner`), which are NOT the same as the ones it needs at the PLAN +/// endpoint (`api/plan`). +/// +/// Dynamic RP is the case that forced this to exist, and it is the sharpest +/// rule in the whole set because the two endpoints disagree: +/// +/// api/plan Variant 'client_registration' has been set by user, but test +/// plan already sets this variant +/// api/runner createTestModule failed: Missing value for required variant +/// parameter: client_registration +/// +/// Forbidden on one, required on the other, same dimension, same plan. The +/// plan endpoint chooses the value and the module endpoint makes you restate +/// it. +/// +/// No plan needs an entry today. Dynamic RP had one -- client_registration +/// and request_type -- and it was the wrong answer to the right complaint: +/// stating them silenced "Missing value for required variant parameter" and +/// replaced it with a 500 carrying "modifiedCount=0". See +/// [moduleVariantComesFromPlan] for why restating a dimension the plan owns +/// cannot work. The map stays because the plan endpoint's asymmetry is real +/// and the next plan may genuinely need it. +const _extraModuleVariants = >{}; + +/// Plans whose module instances are created with NO `variant` at all, letting +/// the suite fill it in from the plan. +/// +/// The suite attaches a module instance to its plan with a Mongo arrayFilter +/// assembled from the variant the caller sent +/// (`DBTestPlanService.updateTestPlanWithModule`): +/// +/// variant.getVariant().forEach((name, value) -> +/// updateCriteria.and("module.variant." + name).is(value)); +/// updateCriteria.and("module.testModule").is(testName); +/// +/// Each dimension sent is one more equality the STORED module entry has to +/// satisfy. Send a dimension the plan recorded differently -- or did not +/// record -- and the filter matches no array element, the update reports +/// `modifiedCount=1 != 0`, and the whole request 500s with a Java stack trace +/// that names neither the dimension nor the value. +/// +/// Sending nothing is the path the suite is built for: `TestRunner` reaches +/// `getFixedVariantIfOnlyOneMatchingModuleInPlan` -- which hands back the +/// module's own stored variant -- only when no variant arrived from the API. +/// That value satisfies the filter by construction, so this works without the +/// harness ever knowing what the plan chose. +/// +/// Scoped to the one plan that needs it. The other twelve pass while stating +/// their variant, which means theirs already matches what the plan stored. +const _moduleVariantFromPlan = { + 'oidcc-client-dynamic-certification-test-plan', +}; + +/// Whether [planName]'s module instances omit the `variant` parameter. +bool moduleVariantComesFromPlan(String planName) => + _moduleVariantFromPlan.contains(planName); + +/// The `api/runner` URL that creates a module instance, with `variant` present +/// only when [variant] is non-null. +Uri moduleInstanceUri({ + required String planId, + required String moduleName, + Map? variant, +}) => Uri( + path: 'api/runner', + queryParameters: { + 'plan': planId, + 'test': moduleName, + if (variant != null) 'variant': jsonEncode(variant), + }, +); + +/// The extra module-level variant [planName] requires, empty when it needs none. +Map moduleVariantFor(String planName) => + _extraModuleVariants[planName] ?? const {}; + +/// Whether [planName] contains modules that address the running test through +/// the suite's `alias` rather than through the URL `api/runner` hands back. +/// +/// Only the Dynamic RP plan does. Its WebFinger modules expect the RP to look +/// up `acct:.oidcc-client-test-discovery-webfinger-acct@`, and the +/// suite resolves that alias to the running test -- there is no other way to +/// name it. Every other plan reads its test URL straight out of the +/// module-instance response, so they are deliberately left alone: sending an +/// alias changes the URL shape the suite issues, and twelve currently-passing +/// profiles are not worth risking for a field they never read. +bool planNeedsAlias(String planName) => + planName == 'oidcc-client-dynamic-certification-test-plan'; + +/// An alias that survives the suite dispatcher's parse. +/// +/// The dispatcher splits a WebFinger `acct:` resource with +/// `^acct:([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)@.*$`, so a dot inside the alias +/// would be read as the alias/test-name separator and the lookup would resolve +/// to neither. Every character outside that class is folded to `_`. +/// +/// Both [platform] and [planName] are in the key because the CI matrix runs +/// platforms as parallel jobs against one shared suite instance: an alias +/// another job already registered would resolve to that job's test. +String conformanceAlias({required String planName, required String platform}) => + 'oidc_${platform}_$planName'.replaceAll(RegExp('[^a-zA-Z0-9_-]'), '_'); + +/// The WebFinger identifier [moduleName] expects the RP to start discovery +/// from, or null when the module resolves its issuer the ordinary way. +/// +/// Only two modules use WebFinger, and they demand different syntaxes. Each +/// validates the scheme of the resource it was queried with, so the shapes are +/// not interchangeable: +/// +/// acct acct:.@ +/// url https://// +/// +/// The `.` pair is how the suite finds the running test -- +/// its dispatcher splits on the first dot -- which is why [conformanceAlias] +/// must never produce one. +/// +/// This returns the identifier only. Resolving it is the library's job, via +/// `OidcEndpoints.getIssuerViaWebFinger`: the module under test is a test of +/// the RP, so a lookup performed by the harness would prove nothing about +/// package:oidc. +String? webFingerIdentifierFor({ + required String moduleName, + required String alias, + required String host, +}) => switch (moduleName) { + 'oidcc-client-test-discovery-webfinger-acct' => + 'acct:$alias.$moduleName@$host', + 'oidcc-client-test-discovery-webfinger-url' => + 'https://$host/$alias/$moduleName', + _ => null, +}; + +/// Whether [moduleName] finishes as soon as the RP has fetched BOTH the +/// discovery document and the (randomized-path) `jwks_uri`, before any +/// userinfo call. +/// +/// `OIDCCClientTestDiscoveryJwksUriKeys` +/// (openid-certification/conformance-suite, +/// `src/main/java/net/openid/conformance/openid/client/config/OIDCCClientTestDiscoveryJwksUriKeys.java`) +/// overrides `finishTestIfAllRequestsAreReceived` to call `fireTestFinished()` +/// the instant `receivedDiscoveryRequest && receivedJwksRequest` are both +/// true -- i.e. right after the client verifies the id_token's signature +/// against the freshly-fetched jwks_uri, well before a normal login's own +/// userinfo call. `OidcUserInfoSettings.sendUserInfoRequest` (package:oidc) +/// defaults to `true`, so the manager's own userinfo call used to arrive +/// AFTER the suite had already finished the module, and the suite answered it +/// with "Illegal test state change: FINISHED -> RUNNING" -- confirmed on CI +/// run 37252425838 (linux instance Bji4pEGUaWavYTg, windows instance +/// sGDQlFZLvzCkkUY), which turned an otherwise-correct login into a FAILED +/// verdict. `runOidcConformanceTest` in shared_e2e.dart builds exactly this +/// module's manager with userinfo disabled rather than weakening the +/// per-module assertion; see oidc#467. +bool moduleFinishesBeforeUserinfo(String moduleName) => + moduleName == 'oidcc-client-test-discovery-jwks-uri-keys'; + +/// Whether [moduleName] requires a SECOND full authorization interaction +/// after the first one succeeds. +/// +/// `OIDCCClientTestSigningKeyRotation` +/// (openid-certification/conformance-suite, +/// `src/main/java/net/openid/conformance/openid/client/config/OIDCCClientTestSigningKeyRotation.java`) +/// rotates its signing key only once a SECOND `authorize` request arrives +/// (`handleClientRequestForPath` sets `receivedSecondAuthorizationRequest` and +/// calls `configureServerJWKS()` again at exactly that point, not before), and +/// its `finishTestIfAllRequestsAreReceived` override will not fire finished +/// for the CODE response type -- the one this harness drives it with in the +/// Config RP plan -- until BOTH `receivedSecondUserinfoRequest` and +/// `receivedSecondJwksRequest` are true. That needs a second complete login: +/// a second token exchange whose id_token is signed by the ROTATED key (so +/// `package:oidc_core`'s own kid-miss forced-refetch self-heal -- OIDC Core +/// §10.1.1, see `OidcUser.fromIdToken` -- fetches the jwks a second time) and +/// a second userinfo call with the new access_token. Confirmed stuck at +/// `status=WAITING result=null` after only ONE login on CI run 37252425838 +/// (linux instance A8MLwlTP0UizYW0, windows instance WmGCI7Zvht0bmZl) because +/// the harness never issued that second interaction. See oidc#467. +bool requiresSecondLoginForKeyRotation(String moduleName) => + moduleName == 'oidcc-client-test-signing-key-rotation'; + +/// Whether [moduleName] needs OpenID Connect Session Management 1.0 actually +/// turned on for the manager driving it, and a wait for the suite to observe +/// the PRE-logout `check_session_iframe` round trip before logging out. +/// +/// `OIDCCClientTestSessionManagement` +/// (openid-certification/conformance-suite, +/// `src/main/java/net/openid/conformance/openid/client/logout/OIDCCClientTestSessionManagement.java`, +/// extending `AbstractOIDCCClientLogoutTest`) will not fire finished until +/// `receivedAuthorizationRequest && receivedEndSessionRequest && +/// receivedCheckSessionRequestBeforeLogout && +/// receivedCheckSessionRequestAfterLogout` are ALL true. The suite sets the +/// latter two only from `handleGetSessionStateViaAjaxRequest`: the OP's +/// `check_session_iframe` page itself calls back to `get_session_state` +/// (`check_session_ajax_url`) the moment it RECEIVES a postMessage from the +/// RP, logging "OP iframe received postMessage request from RP iframe" -- +/// see [isSessionCheckPostMessageLogEntry]. Loading the iframe alone +/// ("The client requested check_session_iframe") is not enough. +/// +/// `package:oidc`'s own `OidcSessionManagementSettings.enabled` defaults to +/// `false` and gates EVERY piece of this: capturing `session_state` into the +/// logout state, the automatic post-login monitor +/// (`listenToUserSessionIfSupported`, wired to `userChanges` in +/// `user_manager_base.dart`), and the post-logout probe +/// (`startEndSessionConfirmation`, called from `handleEndSessionResponse` +/// right before `forgetUser()`). Confirmed on CI (oidc#467): with it left at +/// the default, login was immediately followed by logout with no +/// `check_session_iframe` traffic at all, and the module sat at +/// `status=WAITING result=null` forever. `conformanceManager`'s +/// `sessionManagementEnabled` parameter is `true` for exactly this module. +/// +/// Even with it enabled, the regular monitor's first postMessage lands on its +/// OWN schedule (iframe load, then `sessionManagementSettings.interval`) -- +/// calling `logout()` immediately after login would very likely race it. The +/// harness polls the suite's log for [isSessionCheckPostMessageLogEntry] +/// before logging out, rather than sleeping a guessed duration. +bool requiresSessionManagementMonitoring(String moduleName) => + moduleName == 'oidcc-client-test-session-management'; + +(String path, Map body) prepareTestPlanRequest({ + // oidcc-client-basic-certification-test-plan + required String planName, + required String description, + required String clientId, + required String redirectUri, + // {"request_type":"plain_http_request","client_registration":"static_client"} + String? clientRegistration, + String? requestType, + String? alias, + String? clientSecret, + String? postLogoutRedirectUri, + String? frontChannelLogoutUri, + // OIDC Registration 1.0 section 2 client metadata; the suite defaults an + // omitted value to `web`, whose redirect_uri rules then reject a loopback + // http redirect for any non-code response type. A native RP must say so. + String? applicationType, + Map? extraVariant, + String publish = 'everything', +}) { + final variant = { + if (requestType != null) 'request_type': requestType, + if (clientRegistration != null) 'client_registration': clientRegistration, + ...?extraVariant, + }; + final missing = (_requiredExtraVariants[planName] ?? const []) + .where((dimension) => !variant.containsKey(dimension)) + .toList(); + if (missing.isNotEmpty) { + throw ArgumentError( + 'The "$planName" plan requires the variant dimension(s) ' + '${missing.join(', ')}. The conformance suite enforces this at plan ' + 'creation and answers HTTP 400, so pass them via extraVariant rather ' + 'than discovering it from CI.', + ); + } + final forbidden = (_forbiddenExtraVariants[planName] ?? const []) + .where(variant.containsKey) + .toList(); + if (forbidden.isNotEmpty) { + throw ArgumentError( + 'The "$planName" plan sets the variant dimension(s) ' + '${forbidden.join(', ')} itself and rejects a caller-supplied value ' + 'with HTTP 400. Drop them rather than discovering it from CI.', + ); + } + final uri = Uri( + path: '/api/plan', + queryParameters: {'planName': planName, 'variant': jsonEncode(variant)}, + ); + final body = { + 'description': description, + 'client': { + 'client_id': clientId, + if (clientSecret != null) 'client_secret': clientSecret, + if (applicationType != null) 'application_type': applicationType, + 'redirect_uri': redirectUri, + if (postLogoutRedirectUri != null) + 'post_logout_redirect_uri': postLogoutRedirectUri, + if (frontChannelLogoutUri != null) + 'frontchannel_logout_uri': frontChannelLogoutUri, + }, + if (alias != null) 'alias': alias, + 'publish': publish, + }; + return (uri.toString(), body); +} + +Future> getPlan({ + required Dio dio, + required String planId, + bool public = false, +}) async { + final uri = Uri( + path: 'api/plan/$planId', + queryParameters: {'public': public.toString()}, + ); + // Assuming you have a Dio instance or similar HTTP client + final response = await dio.getUri>(uri); + return response.data ?? {}; +} + +Future deletePlan({required Dio dio, required String planId}) async { + final uri = Uri(path: 'api/plan/$planId'); + // Assuming you have a Dio instance or similar HTTP client + await dio.deleteUri(uri); +} + +/* +returns: +{ + "name": "oidcc-client-test-invalid-iss", + "id": "5KqBAUA5ZqCKzci", + "url": "https://www.certification.openid.net/test/a/package_oidc_windows" +} +*/ +Future> createTestModuleInstance({ + required Dio dio, + required String planId, + required String moduleName, + String clientAuthType = 'client_secret_basic', + String responseType = 'code', + String responseMode = 'default', + Map? extraVariant, + bool variantFromPlan = false, +}) async { + /* + {"client_auth_type":"client_secret_basic","response_type":"code","response_mode":"default"} + */ + final variant = variantFromPlan + ? null + : { + 'client_auth_type': clientAuthType, + 'response_type': responseType, + 'response_mode': responseMode, + ...?extraVariant, + }; + final uri = moduleInstanceUri( + planId: planId, + moduleName: moduleName, + variant: variant, + ); + // Same treatment prepareTestPlanRequest already gets, for the same reason. + // Dio reports only the status code, and a bare "500" from this endpoint says + // nothing about WHICH of the three dimensions below the suite objected to. + // Dynamic RP fails here, and three earlier mysteries at the plan endpoint + // each turned into a one-line fix the moment the server's own words were + // printed instead of guessed at. + final Response> response; + try { + response = await dio.postUri>(uri); + } on DioException catch (e) { + // `modifiedCount=0` from DBTestPlanService.updateTestPlanWithModule is the + // shape that matters here: it is a Mongo update that matched NOTHING, so + // the suite could not attach the module to the plan. That is not a + // validation complaint about a missing dimension -- it means the + // VariantSelection we sent is not the one the plan recorded for this + // module. Guessing which dimension differs has cost three CI round trips + // already, so on failure dump what the plan actually stored and compare. + var planDump = '(plan could not be read)'; + try { + final plan = await getPlan(dio: dio, planId: planId); + final modules = plan['modules']; + planDump = const JsonEncoder.withIndent( + ' ', + ).convert({'planVariant': plan['variant'], 'modules': modules}); + } on Object catch (inner) { + planDump = '(plan fetch threw: $inner)'; + } + throw StateError( + 'Creating a module instance for "$moduleName" failed with status ' + '${e.response?.statusCode}.\n' + 'Conformance suite response: ${e.response?.data}\n' + 'Request path: $uri\n' + '${variant == null ? 'Variant sent: none (supplied by the plan).\n' : 'Variant sent: $variant\n'}' + 'Note this endpoint takes the variant PER MODULE, and a plan that ' + 'rejects a dimension at plan level may reject it here too.\n' + 'PLAN AS STORED BY THE SUITE (compare its module variant to the one ' + 'sent above):\n$planDump', + ); + } + return response.data ?? {}; +} + +Future> getTestStatus({ + required Dio dio, + required String instanceId, +}) async { + final uri = Uri(path: 'api/runner/$instanceId'); + final response = await dio.getUri>(uri); + return response.data ?? {}; +} + +Future> startTest({ + required Dio dio, + required String instanceId, +}) async { + final uri = Uri(path: 'api/runner/$instanceId'); + final response = await dio.postUri>(uri); + return response.data ?? {}; +} + +Future> cancelTest({ + required Dio dio, + required String instanceId, +}) async { + final uri = Uri(path: 'api/runner/$instanceId'); + final response = await dio.deleteUri>(uri); + return response.data ?? {}; +} + +//api/plan/:id/certificationpackage +Future?> publishCertificationPackage({ + required Dio dio, + required String planId, + required Uint8List clientSideData, +}) async { + final uri = Uri(path: 'api/plan/$planId/certificationpackage'); + + var attempt = 0; + const maxAttempts = 5; + const initialDelay = Duration(seconds: 1); + + while (true) { + try { + final formData = FormData(); + formData.files.add( + MapEntry( + 'clientSideData', + MultipartFile.fromBytes( + clientSideData, + filename: 'client_side_logs.zip', + contentType: DioMediaType('application', 'zip'), + ), + ), + ); + final response = await dio.postUri( + uri, + data: formData, + options: Options( + responseType: ResponseType.bytes, + headers: { + 'Content-Type': 'multipart/form-data', + 'Accept': 'application/zip', + }, + ), + ); + return response.data; + } on DioException catch (e) { + if (e.response?.statusCode != 422 || attempt >= maxAttempts - 1) { + rethrow; + } + + // Calculate backoff delay: initialDelay * 2^attempt, with jitter + final backoff = initialDelay * (1 << attempt); + final jitter = Duration( + milliseconds: + (backoff.inMilliseconds * 0.2 * (Random().nextDouble() * 2 - 1)) + .toInt(), + ); + final delay = backoff + jitter; + + print( + 'Retrying publishCertificationPackage (attempt ${attempt + 1}/$maxAttempts) after ${delay.inMilliseconds}ms', + ); + await Future.delayed(delay); + attempt++; + } + } +} + +Future> getTestSummary({ + required Dio dio, + required String instanceId, +}) async { + final uri = Uri(path: 'api/info/$instanceId'); + final response = await dio.getUri>(uri); + return response.data ?? {}; +} + +/// `TestModule.Result` values (the suite's own source, +/// `net.openid.conformance.testmodule.TestModule`) this harness accepts for +/// ANY module, positive or negative. +/// +/// PASSED is the obvious case. WARNING, REVIEW and SKIPPED are accepted too, +/// matching what certification itself accepts: a profile can be certified +/// with PASSED, REVIEW, WARNING or SKIPPED results, and cannot be certified +/// with FAILED or INTERRUPTED. REVIEW usually asks a human to confirm +/// something like a screenshot; this harness runs unattended and cannot act +/// on it, so treating it as a failure would fail module categories the suite +/// itself does not consider broken. SKIPPED means the suite decided the +/// module could not run against this configuration (e.g. a server-side +/// feature the plan variant does not exercise), not a client defect. +const acceptableConformanceResults = {'PASSED', 'WARNING', 'REVIEW', 'SKIPPED'}; + +/// `TestModule.Status` values after which `TestModule.Result` is final -- +/// the module will not take any further RP-observable step. +/// +/// `TestModule.Status` also has `NOT_YET_CREATED`, `CREATED`, `CONFIGURED`, +/// `RUNNING` and `WAITING`, all non-terminal: the suite can still change its +/// mind about the result while a module is in any of those. +const terminalConformanceStatuses = {'FINISHED', 'INTERRUPTED'}; + +/// Whether the suite's own verdict for a module is one this harness accepts. +/// +/// `null` is never accepted: either the field was absent, or the module's +/// result is still the suite's own `UNKNOWN` ("not yet known, probably still +/// running"), neither of which is a verdict. +bool isAcceptableConformanceResult(String? result) => + result != null && acceptableConformanceResults.contains(result); + +/// Whether [status] is one of `TestModule.Status` after which the module's +/// result will not change further. See [terminalConformanceStatuses]. +bool isTerminalConformanceStatus(String? status) => + status != null && terminalConformanceStatuses.contains(status); + +/// Whether [error], raised by a [getTestSummary] call inside +/// [pollConformanceModuleVerdict], is worth retrying rather than letting it +/// end the poll outright. +/// +/// A transient network hiccup or a suite-side 5xx against ONE poll must not +/// take the rest of the plan down with it: CodeRabbit and a human reviewer +/// both flagged that an uncaught exception here used to propagate out of +/// `pollConformanceModuleVerdict` -- an uncaught `Future` error inside the +/// `testWidgets`/`patrolTest` body running `runOidcConformanceTest` -- failing +/// the whole plan and losing every module after the one being polled, not +/// just the single bad request. A 4xx, a malformed response, or any other +/// non-transient error is NOT retried: retrying cannot change a deterministic +/// failure, and masking it behind a retry delay would only slow the run down +/// before it fails anyway. +bool isTransientConformancePollError(Object error) { + if (error is! DioException) { + return false; + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return true; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + return statusCode != null && statusCode >= 500; + case DioExceptionType.cancel: + case DioExceptionType.badCertificate: + case DioExceptionType.unknown: + return false; + // A timeout while Dio's own response transformer (JSON decode) was + // running, not a network condition -- not expected for this endpoint's + // tiny JSON body, so treated conservatively as non-transient rather than + // retried blind. + case DioExceptionType.transformTimeout: + return false; + } +} + +/// Retries [poll] while it fails with a transient error +/// ([isTransientConformancePollError]), up to [maxAttempts] attempts total, +/// waiting `initialDelay * 2^(attempt - 1)` between tries. +/// +/// The last error is rethrown once attempts are exhausted, or immediately for +/// a non-transient error: this function only decides whether to retry, not +/// what an exhausted/non-transient failure means for the caller's verdict -- +/// see [pollConformanceModuleVerdict], which turns that rethrow into a verdict +/// map rather than letting it escape. +/// +/// [maxAttempts] and [initialDelay] are parameters (not hardcoded) so a test +/// can keep this fast and deterministic without mocking Dio or the clock: the +/// retry COUNT and the transient/non-transient DECISION are the pure logic +/// worth pinning down, and a millisecond-scale [initialDelay] exercises both +/// without a real wait. +Future retryTransientConformancePollErrors( + Future Function() poll, { + int maxAttempts = 3, + Duration initialDelay = const Duration(milliseconds: 500), +}) async { + var attempt = 0; + while (true) { + try { + return await poll(); + } on Object catch (e) { + attempt++; + if (attempt >= maxAttempts || !isTransientConformancePollError(e)) { + rethrow; + } + await Future.delayed(initialDelay * (1 << (attempt - 1))); + } + } +} + +/// One [getTestSummary] read, tolerant of a failure that survives +/// [retryTransientConformancePollErrors]: rather than letting it escape (and +/// taking the rest of the plan down with it), it is folded into a verdict map +/// carrying `pollError`. `isTerminalConformanceStatus(null)` is false, so +/// [pollConformanceModuleVerdict]'s own timeout loop, and +/// `_recordModuleVerdict`'s "never reached FINISHED/INTERRUPTED" message in +/// shared_e2e.dart, already handle a map shaped like this; `pollError` just +/// explains why THIS read could not refresh it. [previous] (the last summary +/// that DID succeed, if any) is carried forward so a poll that fails after the +/// module already reported something is not reported as if nothing had ever +/// been read. +Future> _pollSummaryTolerant({ + required Dio dio, + required String instanceId, + Map? previous, +}) async { + try { + return await retryTransientConformancePollErrors( + () => getTestSummary(dio: dio, instanceId: instanceId), + ); + } on Object catch (e) { + return { + 'status': previous?['status'], + 'result': previous?['result'], + 'pollError': '$e', + }; + } +} + +/// Polls `GET api/info/{id}` ([getTestSummary]) until the suite reports a +/// terminal `TestModule.Status` ([isTerminalConformanceStatus]) or [timeout] +/// elapses, returning whatever the last poll read either way. +/// +/// This is `api/info`, deliberately NOT `api/runner` ([getTestStatus]): +/// confirmed against real CI output (oidc#467) that `api/runner/{id}` -- +/// which this harness already called for null-result diagnostics below -- +/// returns only the TestRunner's live browser-interaction state (`owner`, +/// `created`, `browser`, `name`, `exposed`, `id`, `error`, `updated`), with +/// no `status` or `result` key at all; that is why an earlier version of this +/// harness read `status['status']`/`status['result']` and got `null` for +/// every module, a payload-shape guess that was never corrected. The suite's +/// own OpenAPI document (`frontend/src/api/openapi.json` in +/// openid/conformance-suite) says outright: "Read the outcome from GET +/// /api/info/{id} (status and result)". `TestInfoResponse.status`/`.result` +/// there enumerate exactly `TestModule.Status`/`TestModule.Result`. +/// +/// [timeout] defaults to 30s, widened from an earlier 15s: CI run 37252425838 +/// (the same run that exposed the jwks-uri-keys and signing-key-rotation +/// gaps above) hit `status=WAITING result=null` at the OLD 15s bound for +/// `oidcc-client-test-session-management` on BOTH linux and windows, and that +/// module passed on an unrelated retry -- i.e. the suite was still working, +/// not stuck, when the old bound gave up on it. +/// `AbstractOIDCCClientTest.waitTimeoutSeconds` defaults to 5s in the suite's +/// own source -- the longest a module legitimately waits on purpose, for a +/// negative module expecting the RP to detect a problem and go silent -- so +/// 30s leaves a 6x margin for suite-side load/poll jitter, including the +/// extra round trip [requiresSecondLoginForKeyRotation] modules now make +/// through this same poll budget. +/// +/// Each read is retried through [retryTransientConformancePollErrors] +/// ([_pollSummaryTolerant]) rather than letting a single transient failure +/// (network blip, suite-side 5xx) escape and fail the rest of the plan; a read +/// that still fails after that degrades to a non-terminal verdict map instead +/// of throwing, so a timeout (transient-poll-induced or genuine) still ends in +/// the SAME named per-module failure `_recordModuleVerdict` already produces, +/// rather than an uncaught exception with no module name attached. +Future> pollConformanceModuleVerdict({ + required Dio dio, + required String instanceId, + Duration timeout = const Duration(seconds: 30), + Duration interval = const Duration(seconds: 1), +}) async { + final stopwatch = Stopwatch()..start(); + var summary = await _pollSummaryTolerant(dio: dio, instanceId: instanceId); + while (!isTerminalConformanceStatus(summary['status'] as String?) && + stopwatch.elapsed < timeout) { + await Future.delayed(interval); + summary = await _pollSummaryTolerant( + dio: dio, + instanceId: instanceId, + previous: summary, + ); + } + return summary; +} + +/// The suite's own log for [instanceId]. +/// +/// `public: false` is the authenticated view. The public one omits the entries +/// that carry a failure reason, which is the only reason to fetch this at all. +Uri testLogUri({required String instanceId, int? since}) => Uri( + path: 'api/log/$instanceId', + queryParameters: {'public': 'false', if (since != null) 'since': '$since'}, +); + +/// Reads the suite's log for [instanceId] once. +/// +/// [monitorTestLogs] polls the same endpoint but stops at `Setup Done`, so +/// everything the suite records DURING a module -- including why it refused a +/// request -- is fetched and thrown away. When a module ends with no user the +/// client can only report "no user"; this is the other half of that story. +/// +/// Never throws: it runs on the failure path, where an exception would replace +/// the diagnosis with a second failure. +Future>> fetchTestLogs({ + required Dio dio, + required String instanceId, +}) async { + try { + final response = await dio.getUri>( + testLogUri(instanceId: instanceId), + ); + return (response.data ?? []).cast>(); + } on Object { + return const []; + } +} + +/// The block name `AbstractOIDCCClientTest.getAuthorizationEndpointBlockText` +/// opens for every authorization request the suite receives. +const _authorizationEndpointBlock = 'Authorization endpoint'; + +/// A one-line digest of a module's suite log ([fetchTestLogs]) for a +/// per-module FAILURE line, i.e. for the one channel every platform's job log +/// shows. +/// +/// The iOS job prints no Dart `print`/`logging` output at all -- patrol only +/// forwards the failure the test throws -- so a module stuck at +/// `status=WAITING` there could not say whether the suite ever received its +/// authorization request (the client never reached the OP), or received it +/// and the client's later requests raced the suite's own negative-module +/// timer (`AbstractOIDCCClientTest.startWaitingForTimeout`, which finishes the +/// module only if the status is still WAITING the instant +/// `waitTimeoutSeconds` elapses after the authorization request). The two +/// need opposite fixes, and the suite's log tells them apart. +/// +/// Lists every request block the suite opened (`startBlock` entries: +/// "Discovery endpoint", "Authorization endpoint", "Jwks endpoint", ...) +/// with its time relative to the FIRST authorization request -- both clocks +/// are the suite's, so there is no client/server skew in these offsets -- +/// followed by the last [tailLength] entries verbatim (truncated). +/// +/// [clientLoginStartedAtMs] (the client's epoch ms when it started the login, +/// if it did) adds when the first authorization request reached the suite +/// relative to that. That offset compares the suite's clock with the +/// client's (both NTP-synced, so expect about a second of skew). Good enough +/// to tell "arrived right away" from "arrived 25s in" from "arrived after the +/// client's flowTimeoutSeconds had already cancelled the browser". +String describeSuiteLogForFailure( + List> entries, { + int tailLength = 3, + int? clientLoginStartedAtMs, +}) { + if (entries.isEmpty) { + return 'suite log: empty or unreadable'; + } + int? timeOf(Map entry) => (entry['time'] as num?)?.toInt(); + String clip(Object? value, int max) { + final text = '$value'.replaceAll(RegExp(r'\s+'), ' '); + return text.length <= max ? text : '${text.substring(0, max)}...'; + } + + final blocks = entries.where((e) => e['startBlock'] == true).toList(); + final authorize = blocks + .where((e) => '${e['msg']}'.startsWith(_authorizationEndpointBlock)) + .firstOrNull; + final anchor = authorize == null ? null : timeOf(authorize); + String offset(Map entry) { + final time = timeOf(entry); + if (anchor == null || time == null) { + return ''; + } + final seconds = (time - anchor) / 1000; + return '@${seconds >= 0 ? '+' : ''}${seconds.toStringAsFixed(2)}s'; + } + + final requestBlocks = blocks + .map((e) => '${clip(e['msg'], 60)}${offset(e)}') + .join(', '); + final tail = entries.length <= tailLength + ? entries + : entries.sublist(entries.length - tailLength); + final tailText = tail + .map( + (e) => + '[${e['result'] ?? '-'}]${offset(e)} ${clip(e['msg'], 140)}' + '${e['error'] == null ? '' : ' | error: ${clip(e['error'], 140)}'}', + ) + .join(' / '); + final crossClock = anchor == null || clientLoginStartedAtMs == null + ? '' + : 'first authorization request arrived ' + '${((anchor - clientLoginStartedAtMs) / 1000).toStringAsFixed(2)}s ' + 'after the client started the login (suite vs client clock); '; + return 'suite log (${entries.length} entries): ' + '${authorize == null ? 'NO authorization request received; ' : ''}' + '$crossClock' + 'request blocks (relative to the first authorization request) ' + '[$requestBlocks]; last ${tail.length}: $tailText'; +} + +/// The suite dispatcher's line for every HTTP request a test instance +/// receives, written before the module handles it (TestDispatcher.java logs +/// "Incoming HTTP request to "). +final _incomingAuthorizeRequest = RegExp( + r'^Incoming HTTP request to \S*/authorize(?:[?#\s]|$)', +); + +/// Whether [suiteLog] ([fetchTestLogs]) records that at least one +/// authorization request ARRIVED at the suite, whether or not the suite went +/// on to handle it. +/// +/// Two kinds of entry count: +/// * the dispatcher's "Incoming HTTP request to …/authorize" line, written +/// on arrival, before the module runs. A request the module then refuses +/// leaves only this line. Examples: an instance already FINISHED or +/// INTERRUPTED throws "Illegal test state change" when it is moved to +/// RUNNING (AbstractTestModule.setStatusInternal), and +/// OIDCCClientTestDiscoveryIssuerMismatch throws before opening a block; +/// * the module's own "Authorization endpoint" start block +/// (AbstractOIDCCClientTest.getAuthorizationEndpointBlockText). +bool suiteLogShowsAuthorizationRequest(List> suiteLog) => + suiteLog.any((e) { + final msg = '${e['msg']}'; + return _incomingAuthorizeRequest.hasMatch(msg) || + (e['startBlock'] == true && + msg.startsWith(_authorizationEndpointBlock)); + }); + +/// The most times a module is run on a fresh instance after its browser never +/// reached the suite. See [shouldRerunModuleOnFreshInstance]. +const maxModuleReruns = 1; + +/// Whether a module attempt should be discarded and the module run again on a +/// fresh suite instance. +/// +/// The gate is platform-agnostic: the harness applies it on every platform. +/// The case it exists for is the iOS CI simulator, whose browser sometimes +/// never delivers the authorization request at all (#469). In run 37395358046 +/// the SafariViewService process hosting the session stopped responding and +/// was killed by the watchdog (`0x8badf00d`), and the session never +/// recovered: no timeout is long enough for a browser that is gone. Other +/// runs had WebKit WebContent launches taking 27-155s before the request left +/// the simulator. Elsewhere the conditions below simply never all hold. +/// +/// It requires ALL of: +/// * the client did not log in ([loggedIn] false); +/// * the suite still reports the instance as WAITING ([suiteStatus], from +/// `GET api/info/{id}`, [getTestSummary]). That is the stall's signature: +/// a module waiting for a request that never came. A FINISHED or +/// INTERRUPTED instance has a verdict, and a verdict is never discarded, +/// whatever the log says; +/// * the suite's own log for the instance is readable and shows NO +/// authorization request arriving ([suiteLogShowsAuthorizationRequest]). +/// Arriving counts, not just being handled, so a request the suite +/// refused is not mistaken for one that never came. Every negative +/// module's request arrives, so negative modules are never rerun; +/// * this is not already a rerun ([attempt] counts from 1; +/// [maxModuleReruns] caps it). +/// An empty log means it could not be read, which proves nothing, so it does +/// not qualify either. +bool shouldRerunModuleOnFreshInstance({ + required bool loggedIn, + required int attempt, + required String? suiteStatus, + required List> suiteLog, +}) => + !loggedIn && + attempt <= maxModuleReruns && + suiteStatus == 'WAITING' && + suiteLog.isNotEmpty && + !suiteLogShowsAuthorizationRequest(suiteLog); + +/// Whether suite log entry message [msg] confirms the OP's +/// `check_session_iframe` page completed one postMessage round trip with the +/// RP (`LogGetSessionStateRequest`, openid-certification/conformance-suite: +/// `src/main/java/net/openid/conformance/condition/as/logout/LogGetSessionStateRequest.java`). +/// +/// This is the suite's OWN confirmation that `get_session_state` -- the ajax +/// call `check_session_iframe`'s page makes back to the suite the instant it +/// RECEIVES a postMessage (`AbstractOIDCCClientLogoutTest.handleGetSessionStateViaAjaxRequest`) +/// -- actually fired, which is exactly what flips +/// `receivedCheckSessionRequestBeforeLogout`/`...AfterLogout` +/// (see [requiresSessionManagementMonitoring]). The suite logs one of two +/// messages depending on whether a user happens to be logged in at that +/// instant; both still flip the boolean, so both are matched by this shared +/// prefix: +/// - "OP iframe received postMessage request from RP iframe" +/// - "OP iframe received postMessage request from RP iframe but the user +/// is not logged in" +/// +/// A weaker "The client requested check_session_iframe" entry +/// (`LogCheckSessionIframeRequest`) only confirms the RP loaded the iframe, +/// not that a postMessage reached it, so it is deliberately NOT matched here. +bool isSessionCheckPostMessageLogEntry(String? msg) => + msg != null && + msg.startsWith('OP iframe received postMessage request from RP iframe'); + +/// Polls the suite's own log for [instanceId] ([fetchTestLogs]) until an +/// entry satisfies [matches] or [timeout] elapses, returning whether one was +/// found. +/// +/// Used instead of a blind `Future.delayed` to learn when +/// `monitorSessionStatus`'s periodic `check_session_iframe` postMessage has +/// actually landed (oidcc-client-test-session-management, +/// [requiresSessionManagementMonitoring], oidc#467): the monitor runs on its +/// own schedule (iframe load, then `sessionManagementSettings.interval`), so +/// a guessed sleep either races it (too short, logging out before the suite +/// observed the PRE-logout check) or wastes the run's time budget on every +/// other module (too long). Polling the suite's own confirmation is exact +/// either way, and fails closed: if nothing ever matches, this still returns +/// after [timeout] rather than hanging, and the caller proceeds to logout +/// regardless so [pollConformanceModuleVerdict]'s verdict names the real +/// suite-reported failure rather than the harness hanging silently. +Future waitForSuiteLogEntry({ + required Dio dio, + required String instanceId, + required bool Function(Map entry) matches, + Duration timeout = const Duration(seconds: 20), + Duration interval = const Duration(seconds: 1), +}) async { + final stopwatch = Stopwatch()..start(); + while (true) { + // A failed read counts as "not seen yet": the module's own verdict poll + // still decides pass/fail, so a network blip must not abort the plan. + List> logs; + try { + logs = await retryTransientConformancePollErrors( + () => fetchTestLogs(dio: dio, instanceId: instanceId), + ); + } on Object { + logs = const []; + } + if (logs.any(matches)) { + return true; + } + if (stopwatch.elapsed >= timeout) { + return false; + } + await Future.delayed(interval); + } +} + +/// The `since` value for the next `api/log/{id}` read, and the entries of +/// [batch] not already in [seenIds] (which it updates). +/// +/// The suite returns only entries with `time > since` (LogApi.getTestResults), +/// and several entries routinely share one millisecond. Asking from the last +/// seen time itself would drop any entry written later in that same +/// millisecond -- e.g. `Setup Done` -- forever, so the next read starts one +/// millisecond earlier and already-seen entries are filtered out by `_id`. +({int? since, List> fresh}) takeUnseenLogEntries( + List> batch, + Set seenIds, { + int? since, +}) { + final fresh = >[]; + var next = since; + for (final entry in batch) { + if (entry['_id'] case final Object id when !seenIds.add(id)) { + continue; + } + fresh.add(entry); + if (entry['time'] case final int time) { + final overlapping = time - 1; + if (next == null || overlapping > next) { + next = overlapping; + } + } + } + return (since: next, fresh: fresh); +} + +Stream>> monitorTestLogs({ + required Dio dio, + required String instanceId, + Duration interval = const Duration(seconds: 1), +}) { + late StreamController>> controller; + Timer? timer; + int? since; + final seenIds = {}; + + Future fetchLogs() async { + try { + final uri = Uri( + path: 'api/log/$instanceId', + queryParameters: { + 'public': 'false', + if (since != null) 'since': since.toString(), + }, + ); + + final response = await dio.getUri>(uri); + final batch = takeUnseenLogEntries( + (response.data ?? []).cast>(), + seenIds, + since: since, + ); + since = batch.since; + if (batch.fresh.isNotEmpty && !controller.isClosed) { + controller.add(batch.fresh); + } + } catch (error, st) { + if (!controller.isClosed) { + controller.addError(error, st); + } + } + } + + controller = StreamController>>( + onListen: () { + fetchLogs(); + timer = Timer.periodic(interval, (_) => fetchLogs()); + }, + onCancel: () { + timer?.cancel(); + timer = null; + controller.close(); + }, + onPause: () { + timer?.cancel(); + timer = null; + }, + onResume: () { + timer = Timer.periodic(interval, (_) => fetchLogs()); + }, + ); + + return controller.stream; +} diff --git a/packages/oidc/example/integration_test/conformance/manager.dart b/packages/oidc/example/integration_test/conformance/manager.dart new file mode 100644 index 00000000..9d3e7843 --- /dev/null +++ b/packages/oidc/example/integration_test/conformance/manager.dart @@ -0,0 +1,183 @@ +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; + +/// iOS's conformance flow timeout. Why it is longer than the other +/// platforms' 30s is written up at its use in [conformanceManager]. +const iosConformanceFlowTimeoutSeconds = 90; + +/// The slowest iOS-simulator browser start seen so far: seconds from the +/// login starting to the authorization request reaching the suite +/// (oidcc-client-test-idtoken-sig-none, CI run 37380923417). +/// [iosConformanceFlowTimeoutSeconds] has to stay clear of it. +const observedIosBrowserStartSeconds = 49.5; + +/// The linux/windows conformance flow timeout. Why it is longer than 30s is +/// written up at its use in [conformanceManager]. +const desktopConformanceFlowTimeoutSeconds = 60; + +/// The slowest desktop login that still ended in a redirect: a plan's FIRST +/// login, measured from "Starting login" to the suite's first authorization +/// request (linux implicit, oidcc-client-test, main run 37502027906 attempt +/// 1: 30.07s, just past the old 30s timeout). Every later login in a plan +/// took at most 7.9s. [desktopConformanceFlowTimeoutSeconds] has to stay +/// clear of it. +const observedDesktopFirstLoginSeconds = 30.4; + +// Future> prepareConformanceTest(String token) async {} +OidcUserManager conformanceManager( + String issuer, { + required String clientId, + required String clientSecret, + required Uri redirectUri, + Uri? postLogoutRedirectUri, + Uri? frontChannelLogoutUri, + // oidcc-client-test-discovery-jwks-uri-keys (see + // moduleFinishesBeforeUserinfo in conformance/api.dart) finishes the moment + // the client has fetched BOTH the discovery document and jwks_uri -- before + // a normal login's own userinfo call. Defaulting to true keeps every other + // module's existing behaviour unchanged; only that one module's manager is + // built with this false. + bool sendUserInfoRequest = true, + // OidcSessionManagementSettings.enabled defaults to false, and gates EVERY + // OIDC Session Management 1.0 behaviour the manager has: capturing + // session_state into the logout state, listenToUserSessionIfSupported's + // automatic post-login check_session_iframe monitor, AND + // startEndSessionConfirmation's post-logout probe (user_manager_base.dart). + // Leaving it false is why oidcc-client-test-session-management (see + // requiresSessionManagementMonitoring in conformance/api.dart) saw none of + // the check_session_iframe traffic it waits for. True only for that + // module's manager; every other module is unaffected. + bool sessionManagementEnabled = false, +}) => OidcUserManager.lazy( + discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( + Uri.parse(issuer), + ), + clientCredentials: OidcClientAuthentication.clientSecretBasic( + clientId: clientId, + clientSecret: clientSecret, + ), + store: OidcDefaultStore(), + settings: OidcUserManagerSettings( + redirectUri: redirectUri, + postLogoutRedirectUri: postLogoutRedirectUri, + frontChannelLogoutUri: frontChannelLogoutUri, + options: const OidcPlatformSpecificOptions( + // On headless CI emulators/simulators the system auth browser opens but no + // user can interact, so the redirect never arrives and + // loginAuthorizationCodeFlow hangs. A flowTimeoutSeconds lets each module + // fail-fast (caught by the try/catch in shared_e2e) so the rest of the + // conformance suite still runs. Real conformance is exercised on + // desktop (loopback) + macOS (real-OS browser completes). + // + // Every platform the suite runs on needs one. Leaving it off is not a + // test that fails: it is a job that hangs until the runner kills it, + // reporting no assertion and no stack. + // + // iOS NOTE: the iOS-18 simulator (Xcode 16) auto-completed the redirect in + // ~4.5 min, but the iOS-26 simulator (Xcode 26) hangs — hence iOS now also + // needs the timeout. `prefersEphemeralWebBrowserSession` avoids popups. + macos: OidcNativeOptionsApple( + prefersEphemeralWebBrowserSession: true, + flowTimeoutSeconds: 30, + ), + // iOS gets longer than the others because on the CI simulator the + // browser itself can take that long to START (#469). Each + // ASWebAuthenticationSession is hosted by the long-lived + // SafariViewService, and that process sometimes takes tens of seconds + // to get a WebKit WebContent process to load the authorize URL in. + // Simulator unified log, run 37384969496, + // oidcc-client-test-missing-iat (hybrid): + // 23:09:53.86 session presented + // 23:10:15.47 runningboard: launch request for WebKit.WebContent + // 23:10:21.10 launch response + // 23:10:23.41 decidePolicyForNavigationAction .../authorize (+29.6s) + // 23:10:24.31 redirect to com.bdayadev.oidc.example:/oauth2redirect + // At 30s that is a coin flip against this timeout. Lose it and the + // session is cancelled before the authorization request leaves the + // simulator ("NO authorization request received", missing-iat in run + // 37380923417), or after Safari already started it, so it reaches the + // suite ~49.5s in, long after the client gave up (idtoken-sig-none, same + // run). Either way the module sits at status=WAITING and the plan fails, + // while the library under test did nothing wrong. Positive and negative + // modules are equally exposed; the reports named only negative ones + // because they were the majority of the plan. + // + // 90s is just under twice the worst start observed (~49.5s). It costs + // nothing when the browser is healthy: the flow ends at the redirect, + // and no iOS conformance module legitimately goes without one. + ios: OidcNativeOptionsApple( + prefersEphemeralWebBrowserSession: true, + flowTimeoutSeconds: iosConformanceFlowTimeoutSeconds, + ), + android: OidcNativeOptionsAndroid(flowTimeoutSeconds: 30), + // Desktop was assumed to complete on its own because the loopback + // listener needs no user. That holds only while every module's redirect + // actually arrives. It does not for the Config RP plan: the run stopped + // dead at oidcc-client-test-discovery-issuer-mismatch and GitHub Actions + // killed the job ten minutes later. Same listener on both, so both get + // the same bound. + // + // 30s was too tight for a plan's FIRST login, which pays for the CI + // runner's system browser cold-starting. Login durations ("Starting + // login" -> "[e2e] ... authResult") across 6 runs' linux/windows legs + // (37456369562, 37479380276, 37485961901, 37486189186, 37502027906 + // attempts 1+2), as first login of each plan vs every other login: + // linux first n=46 max 30.4s (+24.6, 12.7, 12.3, 12.1) others max 7.7s + // windows first n=45 max 15.2s (+13.3, 10.2, 8.7) others max 7.9s + // The 30.4s one is the failure that prompted this (main run + // 37502027906, linux implicit): the authorization request reached the + // suite 30.07s after the login started, the listener gave up at 30s, + // and the module was left WAITING. The same module's next attempt + // took 1.9s. + // + // 60s is about twice the worst first login seen. It costs nothing when + // the browser is healthy, because the flow ends at the redirect. It + // also stays inside the "fail before the CI job is killed" bound that + // test/conformance_flow_timeout_test.dart enforces. macOS stays at 30s: + // its worst first login in the same runs was 14.0s. + linux: OidcPlatformSpecificOptions_Native( + flowTimeoutSeconds: desktopConformanceFlowTimeoutSeconds, + ), + windows: OidcPlatformSpecificOptions_Native( + flowTimeoutSeconds: desktopConformanceFlowTimeoutSeconds, + ), + // Web hung the same way and had no knob at all until now: + // hiddenIframeTimeout bounds the silent-renew iframe, not the popup the + // interactive flow actually uses. + // + // 10s matches the other platforms deliberately. web is the only platform + // whose runner imposes a PER-TEST cap -- patrol drives it through + // Playwright -- so the temptation is to shrink this until it fits that + // cap. It does not fit: Hybrid RP is 48 modules and the harness pays + // ~5s of non-flow overhead per module (measured at 34-35s per module + // against a 30s timeout, on the eight back-channel modules of commit + // 05b0c84), so 48 x (10 + 5) + 20 = 740s against Playwright's 600s + // default. That is how Hybrid RP and Implicit RP disappeared from the + // web job while it printed "Failed: 0": a killed test emits no closing + // patrol entry, so it lands in Total and in none of + // successful/failed/skipped (run 90178636000, ~629s each). + // + // The fix is to raise the cap, not to shrink this. Fitting 740s under + // 600s needs 7s, which is within an order of magnitude of a healthy web + // module (~1.9s all in -- Basic RP ran 14 modules in 26s in the same + // job) and would start cutting off real redirects: trading a loud + // failure for a fake one. The web job passes --web-timeout 900000 + // instead. Both bounds are asserted in + // test/conformance_flow_timeout_test.dart. + web: OidcPlatformSpecificOptions_Web(flowTimeoutSeconds: 10), + ), + scope: const [ + OidcConstants_Scopes.openid, + OidcConstants_Scopes.profile, + OidcConstants_Scopes.email, + OidcConstants_Scopes.address, + OidcConstants_Scopes.phone, + ], + userInfoSettings: OidcUserInfoSettings( + sendUserInfoRequest: sendUserInfoRequest, + ), + sessionManagementSettings: OidcSessionManagementSettings( + enabled: sessionManagementEnabled, + ), + ), +); diff --git a/packages/oidc/example/integration_test/helpers.dart b/packages/oidc/example/integration_test/helpers.dart new file mode 100644 index 00000000..df618d0d --- /dev/null +++ b/packages/oidc/example/integration_test/helpers.dart @@ -0,0 +1,67 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:oidc_example/web_redirect_uri.dart'; + +Uri getPlatformRedirectUri() { + return kIsWeb + // this url must be an actual html page. + // see the file in /web/redirect.html for an example. + // + // Derived from the origin the app is actually served from, via the same + // function app_state.dart uses. This is what gets registered with the + // conformance test plan, so a hardcoded origin here would register a + // redirect_uri the app does not send the moment it is served anywhere + // else -- and OIDC Registration section 2 requires it to be served + // somewhere else: https, and not localhost, for the implicit and hybrid + // flows. + ? harnessWebRedirectUri(base: Uri.base) + : Platform.isIOS || Platform.isMacOS || Platform.isAndroid + // scheme: reverse domain name notation of your package name. + // path: anything. + ? Uri.parse('com.bdayadev.oidc.example:/oauth2redirect') + : Platform.isWindows || Platform.isLinux + // using port 0 means that we don't care which port is used, + // and a random unused port will be assigned. + // + // this is safer than passing a port yourself. + // + // note that you can also pass a path like /redirect, + // but it's completely optional. + ? Uri.parse('http://localhost:22434') + : Uri(); +} + +/// The `frontchannel_logout_uri` registered with the conformance test plan. +/// +/// Platform-branched for the same reason [getPlatformRedirectUri] is: only web +/// has an http(s) origin to derive from. `Uri.base` is a `file://` URI on every +/// other platform, and deriving a web URI from it throws before any test runs. +/// +/// The non-web value is the fixed literal this harness has always sent. Front- +/// channel logout needs a browser-reachable page, which a private-use scheme +/// cannot provide, so there is no per-platform value to compute -- the logout +/// profiles that depend on it are gated out on those platforms anyway. +Uri getPlatformFrontChannelLogoutUri() => kIsWeb + ? webFrontChannelLogoutUri(base: Uri.base) + : Uri.parse( + 'http://localhost:22433/redirect.html?requestType=front-channel-logout', + ); + +String getPlatformName() { + return kIsWeb + ? 'Web' + : Platform.isAndroid + ? 'android' + : Platform.isIOS + ? 'ios' + : Platform.isMacOS + ? 'macos' + : Platform.isWindows + ? 'windows' + : Platform.isLinux + ? 'linux' + : Platform.isFuchsia + ? 'fuchsia' + : Platform.operatingSystem; +} diff --git a/packages/oidc/example/integration_test/offline_mode_test.dart b/packages/oidc/example/integration_test/offline_mode_test.dart new file mode 100644 index 00000000..47b38e6b --- /dev/null +++ b/packages/oidc/example/integration_test/offline_mode_test.dart @@ -0,0 +1,41 @@ +// integration_test (testWidgets) harness for the offline-mode e2e flow. +// Used by the macOS CI job (`flutter test integration_test`). The android/iOS/ +// linux/windows jobs run the same assertions through Patrol via +// ../patrol_test/offline_mode_test.dart. Shared logic lives in +// shared_offline.dart, so both harnesses run identical tests. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:oidc_example/main.dart' as example; + +import 'shared_offline.dart'; + +var _appStarted = false; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Offline mode integration', () { + Future launch(WidgetTester tester) async { + if (!_appStarted) { + example.main(); + _appStarted = true; + } + await tester.pumpAndSettle(); + } + + testWidgets('enters offline mode after refresh failure', (tester) async { + await runOfflineEntersOfflineMode(() => launch(tester), tester.pump); + }); + + testWidgets('exits offline mode after subsequent success', (tester) async { + await runOfflineExitsAfterSuccess(() => launch(tester), tester.pump); + }); + + testWidgets('emits warning after repeated refresh failures', ( + tester, + ) async { + await runOfflineEmitsWarning(() => launch(tester), tester.pump); + }); + }); +} diff --git a/packages/oidc/example/integration_test/shared_e2e.dart b/packages/oidc/example/integration_test/shared_e2e.dart new file mode 100644 index 00000000..8df53bad --- /dev/null +++ b/packages/oidc/example/integration_test/shared_e2e.dart @@ -0,0 +1,1392 @@ +// ignore_for_file: avoid_print +// +// Harness-agnostic OIDC e2e logic, shared by both runners: +// * `integration_test` (testWidgets) — used by the linux/windows CI jobs +// * Patrol (patrolTest) — used by android/iOS/macOS/web +// +// The ONLY coupling to the test harness is a `pumpAndSettle` callback, so the +// exact same conformance flow runs everywhere. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:oidc/oidc.dart'; +import 'package:oidc_example/app_state.dart' as app_state; + +import 'conformance/api.dart'; +import 'conformance/manager.dart'; +import 'helpers.dart'; + +/// A harness-agnostic "launch the example app (and settle)" hook. Each runner +/// provides its own: integration_test runs the app's `main()`; Patrol pumps a +/// widget via the PatrolTester instead, to avoid double-initializing the +/// Flutter engine (Patrol already bootstraps the app via `$dartRunMain`). +typedef LaunchApp = Future Function(); + +const String oidcConformanceToken = String.fromEnvironment( + 'OIDC_CONFORMANCE_TOKEN', +); + +final Logger _testLogger = Logger('oidc.conformance'); + +/// Test-only: the module whose first attempt is made to stall (see its use in +/// [runOidcConformanceTest]). Empty, the default, disables it. +const String conformanceForceStallModule = String.fromEnvironment( + 'CONFORMANCE_FORCE_STALL_MODULE', +); + +/// The forced stall happens once per process, not once per matching module +/// variant. +bool _forcedStallUsed = false; + +/// The Config RP module whose correct outcome is `init()` throwing: the RP +/// must stop after fetching a discovery document with the wrong `issuer`. +const discoveryIssuerMismatchModule = + 'oidcc-client-test-discovery-issuer-mismatch'; + +/// Whether [planName] is one of the four logout profiles. +/// +/// Matched on substring rather than an enumerated list: the logout plans also +/// ship `-rp-hybrid` and `-rp-implicit` variants that are not wired yet, and a +/// list would silently drive those as login-only when they are added -- the +/// same failure this predicate exists to remove. +bool isLogoutConformancePlan(String planName) => + planName.contains('-logout-') || planName.contains('-session-management-'); + +// There is deliberately no fragment gate any more. A fragment never reaches a +// server, so a bare loopback listener could not observe one and linux/windows +// skipped the hybrid and implicit plans. oidc_loopback_listener 1.1.0 serves a +// relay page that promotes `location.hash` into the query string -- the trick +// CLI OAuth tools use -- and oidc_desktop 1.1.0 turns it on exactly when the +// flow's response mode calls for it (responseArrivesInFragment). Every +// platform can now receive a fragment response, and a universally-true gate is +// dead code. + +/// Whether [planName] is the third-party-initiated login profile. +/// +/// In that flow the OP starts the login by calling the RP's +/// `initiate_login_uri` (OIDC Core §4). The RP must therefore HOST an endpoint +/// the OP can reach and act on. This example is a Flutter app -- it has no +/// server, and `redirect.html` on web only receives a redirect, it cannot be +/// invoked to begin one. So the module waits for the app to react to a call it +/// never receives. +/// +/// Observed on linux at de71aa7: the plan created 1 module and the job log +/// simply ends there, with `dynamic` and `config` never reaching the runner -- +/// this module consumed the remaining job budget. Everything before it passed +/// or skipped correctly. +bool isThirdPartyInitPlan(String planName) => + planName.contains('3rd-party-init'); + +/// Whether [planName] requires the RP to HOST a request object the suite +/// fetches, rather than to send one. +/// +/// The Dynamic plan pins `request_type=request_uri` at plan level for every one +/// of its modules (`OIDCCClientDynamicTestPlan.java:41`), and the suite resolves +/// it by fetching: +/// +/// callAndStopOnFailure(FetchRequestUriAndExtractRequestObject.class, +/// "OIDCC-6.2") -- AbstractOIDCCClientTest.java:1019 +/// +/// `ClientRequestType` has exactly three values -- `plain_http_request`, +/// `request_object`, `request_uri` -- and none is PAR, so pushing a signed +/// request object through the PAR endpoint does not satisfy it either. The RP +/// must serve the object at an https URL the suite can reach, and a CI runner +/// behind NAT cannot. That is the same inbound-reachability wall as +/// [isBackChannelLogoutPlan], not a missing library feature: request objects +/// BY VALUE are implemented (`oidc_core/lib/src/jar/`). +/// +/// This gate is about the harness, not the library. `package:oidc` supports +/// dynamic client registration -- the other half of this profile -- and it is +/// exercised by the oidc_core suite. +bool planNeedsHostedRequestUri(String planName) => + planName == 'oidcc-client-dynamic-certification-test-plan'; + +/// Whether [planName] is the Back-Channel Logout profile. +/// +/// Back-Channel Logout is the one logout profile with no browser in the loop: +/// the OP POSTs a logout token DIRECTLY to the RP's `backchannel_logout_uri` +/// (OpenID Connect Back-Channel Logout 1.0 section 2.5). That makes it the only +/// profile here whose transport runs inbound, from the public internet to the +/// RP. +/// +/// A CI runner's RP listens on loopback behind NAT, so certification.openid.net +/// cannot reach it, and no `backchannel_logout_uri` value fixes that -- the URI +/// is missing from the plan request because there is no reachable value to put +/// there, not the other way round. On linux all eight of its modules therefore +/// ended in "The end session flow timed out after 30 seconds", while the plan +/// still reported success because the aggregate counted logins only. +/// +/// Running it would need a publicly reachable tunnel to the runner. Until then +/// this is stated rather than scored. +bool isBackChannelLogoutPlan(String planName) => + planName.contains('back-channel-logout'); + +/// Whether the suite can compute a `session_state` for [redirectUri]. +/// +/// OpenID Connect Session Management derives `session_state` from the Client's +/// ORIGIN, and the suite builds that origin as `scheme://host` taken from +/// `redirect_uri` (`GenerateSessionState.java:69`). Every logout plan inherits +/// that condition through +/// `AbstractOIDCCClientLogoutTest.validateAuthorizationEndpointRequestParameters`, +/// so it runs on the AUTHORIZATION request, before any redirect is issued. +/// +/// A private-use URI scheme has no authority, so `URI.getHost()` returns null +/// and the suite throws an NPE it does not catch (its handler covers only +/// `URISyntaxException`). The module then never redirects, the client waits out +/// `flowTimeoutSeconds`, and the plan reports zero logins. +/// +/// The redirect URI is NOT the thing to change. RFC 8252 section 7.1: "as there +/// is no naming authority for private-use URI scheme redirects, only a single +/// slash ('/') appears after the scheme component". Giving it an authority to +/// please the suite would break the BCP this package exists to conform to, on +/// every native plan, to work around an upstream defect. It is also consistent +/// with README.md scoping Session Management as web-only: a native app has no +/// origin for `session_state` to be computed from in the first place. +bool canGenerateSessionState(Uri redirectUri) => redirectUri.host.isNotEmpty; + +/// Whether [planName] is an OpenID Connect Session Management RP profile +/// (`oidcc-client-rp-session-management-rp-basic`, plus the not-yet-wired +/// `-rp-hybrid`/`-rp-implicit` variants, matched the same way +/// [isLogoutConformancePlan] matches its own substring family). +bool isSessionManagementConformancePlan(String planName) => + planName.contains('-session-management-'); + +/// Whether this platform can satisfy OpenID Connect Session Management's +/// RP-side session-status check: hosting `check_session_iframe` in a frame +/// the RP controls, posting `"$clientId $sessionState"` into it, and reading +/// back the OP's postMessage result. +/// +/// This is `true` ONLY on web (`package:oidc`'s decision, made and verified +/// with the user, oidc#467): two independent reasons, either one sufficient +/// on its own, rule out every other platform: +/// +/// * RFC 8252 section 8.12 requires native apps to perform the authorization +/// request in an external user-agent (the system browser), explicitly +/// forbidding an embedded WebView for login. So on android/ios/macos/ +/// linux/windows, package:oidc's login flow never runs inside any +/// app-controlled browser surface at all -- there is no WebView around, +/// embedded or otherwise, for the app to host a frame in. +/// * Even granting an embedded WebView purely for session monitoring (a +/// SEPARATE browser surface from the one that performed login), OpenID +/// Connect Session Management 1.0 section 3.2 computes `check_session_iframe`'s +/// answer from the OP SESSION COOKIE held by whichever user-agent is +/// loading it. That cookie lives in the EXTERNAL user-agent the §8.12 login +/// happened in (the system browser), not in any separate surface the app +/// might create -- a second, app-controlled WebView would carry no OP +/// session cookie and could only ever answer "changed"/"error", which is +/// not a meaningful session check, it is a permanently-broken one. +/// +/// `monitorSessionStatus` reflects exactly this split today: +/// `oidc_web_core`'s implementation does the real iframe + postMessage dance +/// (the web page IS the browser the login ran in, so the cookie is right +/// there); `oidc_desktop`/`oidc_android`/`oidc_darwin` each return +/// `Stream.empty()`, tested explicitly in their own `library_surface_test.dart` +/// files. See `packages/oidc/README.md`'s Session Management section for the +/// native-platform alternatives (`OidcTokenRefreshFailedEvent`, +/// `OidcUserInfoFailedEvent`, back-channel logout, OpenID Connect Native SSO). +bool get supportsSessionManagement => kIsWeb; + +/// The OIDC Registration 1.0 section 2 `application_type` this platform's RP +/// truthfully is. +/// +/// The suite defaults an omitted value to `web`, and a web client "using the +/// OAuth Implicit Grant Type MUST only register URLs using the https scheme as +/// redirect_uris; they MUST NOT use localhost as the hostname" -- which is how +/// the first live run of the hybrid and implicit plans on linux produced 36 +/// suite-side rejections of the loopback redirect before any browser was +/// involved. +/// +/// Every non-web platform here is a native client, and the same section +/// sanctions exactly their redirect shapes: "Native Clients MUST only register +/// redirect_uris using custom URI schemes or loopback URLs using the http +/// scheme; loopback URLs use localhost or the IP loopback literals". Declaring +/// `native` is not a workaround for the check; it is the metadata that was +/// always true and simply never sent. +String applicationTypeForPlatform(String platform) => + platform.toLowerCase() == 'web' ? 'web' : 'native'; + +/// Whether [planName] uses `response_mode=form_post`. +bool isFormPostConformancePlan(String planName) => + planName.contains('-formpost-'); + +/// Whether an authorization response can be delivered as a form POST to +/// [redirectUri] on [platform]. +/// +/// `response_mode=form_post` has the browser deliver the response as an HTTP +/// POST body to the redirect URI. Per transport: +/// * loopback (linux/windows) -- YES since oidc_loopback_listener 1.1.0, +/// which reads an application/x-www-form-urlencoded body and folds it into +/// the returned Uri's query parameters. Earlier versions answered 405 to +/// every non-GET, and an earlier version of this predicate returned false +/// everywhere because of it. +/// * custom scheme (iOS/macOS/Android) -- NO: not an HTTP endpoint at all, +/// so no browser can POST to it. Caught by the scheme check. +/// * web -- NO: redirect.html reads location.hash/search in JS, and a page +/// script has no access to the request body that delivered it. This is why +/// [platform] is a parameter: the web page and the desktop listener are +/// both http(s), so the URI alone cannot tell them apart. +/// +/// The predicate stays a real capability statement rather than +/// `scheme == http`: that earlier shortcut ran the formpost plans into the +/// listener's 405, burned flowTimeoutSeconds per module, and reported an +/// Android-specific failure message on Linux. +bool canReceiveFormPost(Uri redirectUri, String platform) { + if (platform.toLowerCase() == 'web') { + return false; + } + return redirectUri.isScheme('http') || redirectUri.isScheme('https'); +} + +bool _planIdsLogged = false; + +/// Logs every RP plan id the suite actually publishes, once per run. +/// +/// Plan ids have been the single biggest source of wasted CI round trips here: +/// a wrong one is an HTTP 400 at creation, and the logout profiles could not be +/// wired at all because their ids were never found in any public document -- +/// openid.net names the four logout PROFILES but not their plan ids, and the +/// only concrete name findable elsewhere turned out to be an OP-side plan. +/// +/// Guessing was refused, and that was right; but "unverifiable" was wrong. The +/// suite will simply list them, and CI holds the token that makes it answer. +/// Read the ids out of the CI log rather than searching for them again. +Future _logAvailableClientPlanIds(Dio dio) async { + if (_planIdsLogged) { + return; + } + _planIdsLogged = true; + try { + final resp = await dio.get>('api/plan/available'); + final names = + (resp.data ?? []) + .whereType>() + .map((e) => e['planName'] as String?) + .whereType() + .where((e) => e.contains('client')) + .toList() + ..sort(); + _testLogger.info('Available RP plan ids (${names.length}):'); + for (final name in names) { + _testLogger.info(' PLAN_ID $name'); + } + } on Object catch (e) { + // Never fail a conformance run over a diagnostic. + _testLogger.warning('Could not list available plans: $e'); + } +} + +bool _loggingConfigured = false; + +/// Configures hierarchical logging once, printing every record. +void ensureLoggingConfigured() { + if (_loggingConfigured) { + return; + } + hierarchicalLoggingEnabled = true; + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + final buffer = StringBuffer() + ..write('[${record.time.toIso8601String()}]') + ..write('[${record.level.name}]') + ..write('[${record.loggerName}] ') + ..write(record.message); + if (record.error != null) { + buffer + ..write(' | error: ') + ..write(record.error); + } + if (record.stackTrace != null) { + buffer.write('\n${record.stackTrace}'); + } + print(buffer); + }); + _loggingConfigured = true; +} + +/// Describes a token by shape, never by value. +/// +/// Every record reaches stdout via the root listener above and is archived with +/// the conformance logs; both are public artifacts, so bearer material must not +/// appear in either. The shape is what the assertion cares about anyway. +String _describeToken(OidcToken token) { + final present = [ + if (token.accessToken != null) 'access', + if (token.idToken != null) 'id', + if (token.refreshToken != null) 'refresh', + ]; + return 'tokens=[${present.join(', ')}] type=${token.tokenType} ' + 'expiresIn=${token.expiresIn?.inSeconds}s ' + 'scope=${token.scope?.join(' ')}'; +} + +/// Appends a failure line to [moduleFailures] when [verdict] (from +/// [pollConformanceModuleVerdict]) is not one this harness accepts for +/// [moduleName], and always logs what the suite reported. +/// +/// [authDescription] is purely descriptive context for the log/failure +/// message (what the CLIENT observed -- logged in, no user, or not driven at +/// all for the discovery-only module) and plays no part in the verdict: the +/// decision is the suite's own `result`, not what package:oidc returned. +/// +/// A rejected verdict also carries [describeSuiteLogForFailure]'s digest of +/// the module's suite log, fetched from [dio] only on that path: the failure +/// line is the one piece of output every platform's job log shows (iOS shows +/// nothing else), and "WAITING, no user" alone cannot say whether the suite +/// ever received the authorization request. +Future _recordModuleVerdict({ + required Dio dio, + required String instanceId, + required List moduleFailures, + required Logger logger, + required String moduleName, + required Map verdict, + required String authDescription, + int? clientLoginStartedAtMs, +}) async { + final status = verdict['status'] as String?; + final result = verdict['result'] as String?; + // Set only when every poll inside pollConformanceModuleVerdict exhausted its + // retries (or hit a non-transient error) -- see _pollSummaryTolerant in + // api.dart. Surfaced here, not swallowed, so a transient-poll-induced + // non-terminal status is distinguishable from a module that is genuinely + // stuck. + final pollError = verdict['pollError'] as String?; + logger.info( + 'Suite verdict for $moduleName: status=$status result=$result ' + '(client: $authDescription)' + '${pollError == null ? '' : ', last poll error: $pollError'}.', + ); + final terminal = isTerminalConformanceStatus(status); + if (terminal && isAcceptableConformanceResult(result)) { + return; + } + final suiteLog = describeSuiteLogForFailure( + await fetchTestLogs(dio: dio, instanceId: instanceId), + clientLoginStartedAtMs: clientLoginStartedAtMs, + ); + if (!terminal) { + moduleFailures.add( + '$moduleName: suite status never reached FINISHED/INTERRUPTED within ' + 'the poll timeout (last status=$status, result=$result; client: ' + '$authDescription)' + '${pollError == null ? '' : ' -- the last poll of it failed: $pollError'}' + '. $suiteLog', + ); + } else { + moduleFailures.add( + '$moduleName: suite result was $result (status=$status; client: ' + '$authDescription). Acceptable results are PASSED, WARNING, REVIEW, ' + 'SKIPPED. $suiteLog', + ); + } +} + +/// A short label for a native browser-layer event, for a failure line. +String describeNativeBrowserEvent(OidcNativeBrowserEvent event) => + switch (event) { + OidcBrowserOpeningEvent() => 'opening', + OidcBrowserOpenedEvent() => 'opened', + OidcBrowserRedirectReceivedEvent() => 'redirectReceived', + // On darwin/android this is also what flowTimeoutSeconds produces. + OidcBrowserFlowCancelledEvent() => 'cancelled', + final OidcBrowserFlowFailedEvent e => + 'failed(${e.error.kind.name}: ${e.error.message})', + OidcBrowserNativeWarningEvent() => 'warning', + }; + +/// Smoke path used when no conformance token is supplied: just initialize the +/// example's default manager. +Future runManagerSmokeTest(LaunchApp launchApp) async { + _testLogger.info('Running smoke test path (no OIDC token supplied).'); + print('Starting test: Simple manager initializes correctly'); + await launchApp(); + print('App launched'); + + if (!app_state.currentManagerRx.$.didInit) { + print('Initializing manager...'); + await app_state.currentManagerRx.$.init(); + print('Manager initialization complete'); + } + + expect(app_state.currentManagerRx.$.didInit, true); + print('Verified that manager is initialized'); +} + +/// Full OIDC conformance flow against certification.openid.net. +/// Runs one OpenID Connect RP certification plan end to end. +/// +/// [planName] is a conformance-suite plan id. Each plan is driven as its own +/// test case rather than looped here, so a failure names the profile that broke +/// and one profile's outage cannot mask another's. +/// +/// Plan ids, all four confirmed green against the live suite: +/// oidcc-client-basic-certification-test-plan - 14 modules; the certified +/// profile +/// oidcc-client-hybrid-certification-test-plan - 48 modules; includes the +/// invalid/missing c_hash +/// negatives +/// oidcc-client-implicit-certification-test-plan - 27 modules +/// oidcc-client-config-certification-test-plan - 6 modules; OP config +/// from .well-known +/// +/// The flow each module needs is read from that module's own `response_type` +/// variant rather than assumed, so hybrid and implicit need no special driving +/// here -- only their plan id. +/// +/// The logout profiles ARE wired now, and they are two-step: log in, then +/// logout. See [isLogoutConformancePlan] -- driving only the login leaves every +/// module waiting for an end-session request that never arrives. +/// +/// [clientRegistration], [requestType] and [clientAuthType] are the plan's +/// variant dimensions, and which ones a plan REQUIRES is not uniform. The +/// basic plan resolves `client_auth_type` to `client_secret_basic` on its own, +/// so omitting it works there. The config plan does not: +/// +/// TestModule 'oidcc-client-test-discovery-openid-config' requires a value +/// for variant 'client_auth_type' +/// +/// which is an HTTP 400 at plan creation, before any module runs. Pass +/// [clientAuthType] for any plan whose modules need it stated outright. +Future runOidcConformanceTest( + LaunchApp launchApp, { + String planName = 'oidcc-client-basic-certification-test-plan', + String? clientRegistration = 'static_client', + String? requestType = 'plain_http_request', + String? clientAuthType, + Map extraPlanVariant = const {}, +}) async { + _testLogger.info('Running OIDC conformance plan: $planName'); + await launchApp(); + _testLogger.info('Example app launched and settled.'); + + const baseUrl = 'https://www.certification.openid.net/'; + _testLogger.fine('Conformance base URL: $baseUrl'); + + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + headers: { + 'Authorization': 'Bearer $oidcConformanceToken', + 'Accept': 'application/json', + }, + ), + ); + if (kIsWeb) { + // The suite sends no CORS headers, so a browser has to go through a + // CORS-proxying Cloudflare Worker instead of calling it directly. That + // worker's calling convention is a single query parameter: + // `.../corsproxy/?apiurl=`. + // + // This used to be done by baking a half-finished `apiurl=` + // into [BaseOptions.baseUrl] and relying on dio's `RequestOptions.uri` + // getter -- a plain `baseUrl + path` STRING CONCATENATION, not URI + // resolution (see package:dio's `lib/src/options.dart`) -- to extend + // that value as each call's `path` was appended. That only produces a + // correct `apiurl` for a `path` with no leading `/` and no query string + // of its own: a leading `/` lands right after the baseUrl's already + // percent-encoded trailing slash (`%2F`) and decodes to a literal + // double slash, and an embedded `?`/`&` -- which every endpoint that + // takes its own query parameters has, being built via + // `Uri(path: ..., queryParameters: {...})` -- breaks OUT of the + // `apiurl` value and starts new top-level query parameters on the + // proxy request that the worker has no use for and drops. + // + // `POST api/plan` (`conformance/api.dart`'s `prepareTestPlanRequest`) + // hit both: its path has a leading `/` AND its own `planName`/`variant` + // query, so the worker actually proxied to + // `https://www.certification.openid.net//api/plan?planName=...` with + // `variant` silently dropped. The suite's router does not recognize + // that double-slash path as the authenticated REST endpoint and falls + // back to its default web handling: a 302 to `login.html`, which the + // browser refuses to follow cross-origin (`net::ERR_FAILED`), surfacing + // here as "Creating the plan test plan failed with status null". + // `api/server`, `api/currentuser` and `api/plan/available` all pass a + // bare relative path with no leading slash and no query of its own, so + // the same string concatenation happens to land on the right value -- + // which is exactly why only plan *creation* was failing. + // + // Fixed by not pre-baking `apiurl` at all: let dio resolve this + // request's OWN baseUrl+path+queryParameters first -- against the + // suite's bare origin, which carries no dangling query, that + // composition is correct for any path, leading slash or embedded query + // or not -- then wrap the single resulting absolute URL as the proxy's + // `apiurl` value via [Uri.replace], which percent-encodes it correctly + // regardless of what it contains. + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + final target = options.uri; + options + ..baseUrl = '' + ..queryParameters = {} + ..path = + Uri.parse('https://cors-proxy.bdaya-dev.workers.dev/corsproxy/') + .replace(queryParameters: {'apiurl': target.toString()}) + .toString(); + handler.next(options); + }, + ), + ); + } + _testLogger + ..info('Dio client configured for conformance API.') + ..info('Fetching server diagnostics (api/server)...'); + final serverInfo = await dio.get>('api/server'); + _testLogger.info('Server info OK (status ${serverInfo.statusCode}).'); + expect(serverInfo.statusCode, 200); + + _testLogger.info('Fetching current user (api/currentuser)...'); + final currentUser = await dio.get>('api/currentuser'); + _testLogger.info('Current user OK (status ${currentUser.statusCode}).'); + expect(currentUser.statusCode, 200); + + await _logAvailableClientPlanIds(dio); + + final platform = getPlatformName(); + _testLogger.info('Detected platform: $platform'); + + if (isThirdPartyInitPlan(planName)) { + markTestSkipped( + '$planName needs the RP to host an initiate_login_uri the OP can call ' + '(OIDC Core §4). This example app hosts no such endpoint on any ' + 'platform, so the module waits for a call that never arrives.', + ); + return; + } + + if (planNeedsHostedRequestUri(planName)) { + markTestSkipped( + '$planName pins request_type=request_uri for every module and the suite ' + 'fetches that URI (FetchRequestUriAndExtractRequestObject, ' + 'AbstractOIDCCClientTest.java:1019). The RP would have to serve a signed ' + 'request object at an https URL reachable from ' + 'certification.openid.net, which a CI runner behind NAT cannot. PAR does ' + 'not substitute: ClientRequestType has no PAR value. Dynamic client ' + 'registration itself IS implemented and covered by the oidc_core suite.', + ); + return; + } + + if (isBackChannelLogoutPlan(planName)) { + markTestSkipped( + '$planName needs the OP to POST a logout token directly to the RP, and a ' + 'CI runner listening on loopback is not reachable from ' + 'certification.openid.net. Every module timed out waiting for that POST ' + 'while the plan reported success, because the aggregate counted logins ' + 'only. Unskip once the runner is publicly reachable.', + ); + return; + } + + if (isSessionManagementConformancePlan(planName) && + !supportsSessionManagement) { + // Confirmed against the suite's own public log (oidc#467, CI run + // 37273454165, linux instance LGM4mkXj5oBdTTq / windows instance + // 4l3cX3OUWBhPQYg): the RP's observed request sequence was discovery -> + // authorize -> token -> jwks -> userinfo -> end_session_endpoint, with + // ZERO requests to check_session_iframe before or after logout, so the + // module sits at status=WAITING forever -- no poll timeout fixes a + // module waiting on an interaction that structurally never happens on + // this platform. See [supportsSessionManagement] for why. + markTestSkipped( + '$planName needs the RP to host check_session_iframe in a frame it ' + 'controls and read back its postMessage result. RFC 8252 section 8.12 ' + 'requires $platform to run login in an external user-agent (no ' + 'embedded WebView), and OpenID Connect Session Management 1.0 section ' + "3.2 ties check_session_iframe's answer to the OP session cookie held " + 'by THAT external user-agent -- a cookie this app has no access to and ' + 'no frame to host against. package:oidc implements session-status ' + "monitoring for web only; see packages/oidc/README.md's Session " + 'Management section for native alternatives ' + '(OidcTokenRefreshFailedEvent, OidcUserInfoFailedEvent, back-channel ' + 'logout, OpenID Connect Native SSO).', + ); + return; + } + + if (isLogoutConformancePlan(planName) && + !canGenerateSessionState(getPlatformRedirectUri())) { + // Not a client defect and not a skipped failure: the suite aborts before it + // ever issues a redirect, so there is no client behaviour to observe. + markTestSkipped( + '$planName runs GenerateSessionState on the AUTHORIZATION request, which ' + 'builds an origin as scheme://host from redirect_uri ' + '(GenerateSessionState.java:69). $platform redirects to ' + '${getPlatformRedirectUri()}, a private-use scheme with no authority, so ' + 'getHost() is null and the suite throws before issuing any redirect. ' + 'RFC 8252 section 7.1 requires that shape, so the redirect URI is correct ' + 'and the suite cannot run these modules against it.', + ); + return; + } + + if (isFormPostConformancePlan(planName) && + !canReceiveFormPost(getPlatformRedirectUri(), platform)) { + // Not a skipped failure: nothing on this platform can observe a POST body, + // so there is no outcome to test. Two distinct reasons share this gate: + // a custom scheme is not an HTTP endpoint (no browser can POST to it), and + // a web page script cannot read the request body that delivered it. + markTestSkipped( + '$planName delivers the response as a form POST, which $platform cannot ' + 'receive: ${platform.toLowerCase() == 'web' ? 'redirect.html reads ' + 'location.hash/search, and a page script has no access to a ' + 'POST body' : '${getPlatformRedirectUri().scheme}: is not an ' + 'HTTP endpoint, so no browser can POST to it'}.', + ); + return; + } + + const clientId = 'my_client'; + const clientSecret = 'my_client_secret'; + final redirectUri = getPlatformRedirectUri(); + _testLogger.fine('Client ID: $clientId, redirectUri: $redirectUri'); + + final (path, body) = prepareTestPlanRequest( + clientId: clientId, + clientSecret: clientSecret, + planName: planName, + description: 'package:oidc $planName on $platform', + alias: planNeedsAlias(planName) + ? conformanceAlias(planName: planName, platform: platform) + : null, + redirectUri: redirectUri.toString(), + requestType: requestType, + clientRegistration: clientRegistration, + applicationType: applicationTypeForPlatform(platform), + extraVariant: { + if (clientAuthType != null) 'client_auth_type': clientAuthType, + ...extraPlanVariant, + }, + postLogoutRedirectUri: redirectUri.toString(), + frontChannelLogoutUri: getPlatformFrontChannelLogoutUri().toString(), + ); + _testLogger.info('Submitting test plan request to $path...'); + + // A 4xx here is a plan-configuration error, not a protocol failure: the + // variant dimensions a plan accepts differ per plan, and one the plan does + // not declare is rejected outright. Dio's own message reports the status + // code and nothing else, so a wrong variant surfaces as a bare "status code + // of 400" that names neither the plan nor the offending key. The suite does + // say which dimension it rejected, in the response body — surface it, or the + // next person debugging this has to re-run CI to learn what the server + // already told us. + final Response> testPlanResponse; + try { + testPlanResponse = await dio.post>(path, data: body); + } on DioException catch (e) { + final response = e.response; + throw StateError( + 'Creating the "$planName" test plan failed with status ' + '${response?.statusCode}.\n' + 'Conformance suite response: ${response?.data}\n' + 'Request path: $path\n' + 'If this names a variant dimension, the plan disagrees with what this ' + 'test sent: pass or drop it via `clientAuthType`, `clientRegistration`, ' + '`requestType`, or `extraPlanVariant`.\n' + 'Note the local guard in api.dart only checks that a dimension is ' + 'PRESENT or ABSENT, never that its VALUE is one the plan accepts, so a ' + '4xx can still come from a wrong clientAuthType even when the guard is ' + 'satisfied.', + ); + } + _testLogger.info('Test plan response status ${testPlanResponse.statusCode}.'); + expect(testPlanResponse.data, isMap); + + final testPlanData = testPlanResponse.data!; + final testPlanId = testPlanData['id'] as String; + final testPlanModules = testPlanData['modules'] as List? ?? []; + _testLogger.info( + 'Test plan created: id=$testPlanId, modules=${testPlanModules.length}.', + ); + + final archive = Archive(); + + // The plan mixes positive modules with negative ones such as + // oidcc-client-test-invalid-iss, where the OP returns a deliberately broken + // response and a null result is the correct outcome. No single module can be + // required to succeed, so the aggregate is asserted after the loop instead. + var successfulLogins = 0; + // Counted separately because counting logins alone made the logout plans + // report success while every one of their logout calls threw: on linux all + // eight back-channel modules ended with "The end session flow timed out after + // 30 seconds", and the plan still passed. An assertion that cannot observe + // the thing the plan exists to test is not a test of it. + var successfulLogouts = 0; + // Per-module suite verdicts this harness rejects, collected across the + // whole plan rather than failing at the first one. Neither aggregate above + // can see this: a negative module that WRONGLY logs in still increments + // successfulLogins, so the plan stayed green through #447's CI even though + // oidcc-client-test-missing-athash should have ended with no user (#467). + // Asking the suite itself for each module's result (not just login/no-login + // on the client side) is what catches that. + final moduleFailures = []; + // Null when the plan has no discovery-issuer-mismatch module. + bool? issuerMismatchRejected; + + moduleLoop: + for (final testPlanModule + in testPlanModules.whereType>()) { + final moduleName = testPlanModule['testModule'] as String; + final variant = + testPlanModule['variant'] as Map? ?? + {}; + // Set when an earlier instance of this module was discarded because its + // browser never reached the suite (shouldRerunModuleOnFreshInstance, + // api.dart). Carried into this module's verdict line so a rerun is never + // invisible. + String? rerunNote; + + // Each pass runs the module on its own fresh suite instance. Every path + // ends the pass with `break` (or `continue moduleLoop`) except the one + // rerun shouldRerunModuleOnFreshInstance allows. + for (var attempt = 1; ; attempt++) { + final testInstance = await createTestModuleInstance( + dio: dio, + planId: testPlanId, + moduleName: moduleName, + clientAuthType: + variant['client_auth_type'] as String? ?? 'client_secret_basic', + responseType: variant['response_type'] as String? ?? 'code', + responseMode: variant['response_mode'] as String? ?? 'default', + // Dynamic RP sends no variant at all: every dimension stated here becomes + // an equality the plan's stored module entry must satisfy, and one it + // recorded differently makes the attach match nothing. + variantFromPlan: moduleVariantComesFromPlan(planName), + // Module-level variants are NOT the same as plan-level ones. Dynamic RP + // rejects client_registration at api/plan and requires it at api/runner. + extraVariant: moduleVariantFor(planName), + ); + + final testInstanceId = testInstance['id'] as String; + final logger = Logger('oidc.conformance.$moduleName.$testInstanceId'); + final logsToWrite = []; + final sub = Logger.root.onRecord.listen((record) { + final message = + '[${record.time} ${record.level.name}][${record.loggerName}]: ${record.message}'; + logsToWrite.add(message); + }); + final url = testInstance['url'] as String; + logger + ..info('Module starting. Variant: $variant') + ..info('Test instance created: $testInstance') + ..info('Test Instance ID: $testInstanceId, URL: $url') + ..info( + 'Monitoring logs for test instance to wait for ready state: ' + '$testInstanceId', + ); + final setupStopwatch = Stopwatch()..start(); + var pollCount = 0; + monitorLogsLoop: + await for (final logs + in monitorTestLogs(dio: dio, instanceId: testInstanceId).timeout( + const Duration(minutes: 2), + onTimeout: (sink) => sink.addError( + TimeoutException( + '$moduleName ($testInstanceId): no new suite log entry for 2 ' + 'minutes while waiting for "Setup Done"', + ), + ), + )) { + pollCount += 1; + if (pollCount % 5 == 0) { + logger.info( + 'Still waiting for setup... polls=$pollCount, elapsed=${setupStopwatch.elapsed}.', + ); + } + for (final log in logs) { + logger.fine('Log: $log'); + if (log['msg'] == 'Setup Done') { + logger.info('Test instance setup done: $testInstanceId'); + break monitorLogsLoop; + } + } + } + setupStopwatch.stop(); + logger.info( + 'Setup completed after ${setupStopwatch.elapsed} (polls=$pollCount).', + ); + + // The WebFinger modules do NOT issue at the URL above. The suite appends a + // random per-run suffix to the issuer for exactly these two modules, and + // hands the result out only through the WebFinger response -- so this + // lookup is load-bearing: skip it and discovery goes to the wrong issuer. + // + // Resolved through the library rather than the harness's Dio on purpose. + // These modules test whether the RELYING PARTY can do WebFinger; a lookup + // written here would pass while package:oidc still could not. + // + // It runs AFTER the setup wait, not before: the suite's dispatcher refuses + // a WebFinger lookup for a test still in CREATED state ("Please wait for + // the test to be in WAITING state"), and every other suite request this + // loop makes is already gated behind the same wait -- the manager is lazy, + // so its discovery fetch happens at init() below. + var issuer = url; + final webFingerIdentifier = webFingerIdentifierFor( + moduleName: moduleName, + alias: conformanceAlias(planName: planName, platform: platform), + host: Uri.parse(baseUrl).host, + ); + if (webFingerIdentifier != null) { + logger.info('Resolving issuer via WebFinger: $webFingerIdentifier'); + // The suite is a live third party and this Dio carries no timeout, so an + // unbounded lookup would hang the job rather than fail it -- the exact + // failure mode manager.dart's flowTimeoutSeconds exists to prevent, but + // that bounds the browser flow only, not a bare GET. + final resolved = await OidcEndpoints.getIssuerViaWebFinger( + webFingerIdentifier, + ).timeout(const Duration(seconds: 30)); + issuer = resolved.toString(); + logger.info('WebFinger resolved issuer: $issuer'); + } + + final manager = conformanceManager( + issuer, + clientId: clientId, + clientSecret: clientSecret, + redirectUri: redirectUri, + postLogoutRedirectUri: redirectUri, + frontChannelLogoutUri: Uri(path: 'redirect.html'), + // See moduleFinishesBeforeUserinfo (api.dart): this one module's suite + // instance finishes the moment the client has fetched discovery + jwks, + // and the manager's own (otherwise-automatic) userinfo call arrives + // after that, which the suite answers with an "Illegal test state + // change" error that flips an otherwise-correct login to FAILED. + sendUserInfoRequest: !moduleFinishesBeforeUserinfo(moduleName), + // See requiresSessionManagementMonitoring (api.dart): OidcSessionManagementSettings.enabled + // defaults to false, and this module needs it true to get ANY + // check_session_iframe traffic at all -- the automatic post-login + // monitor and the post-logout probe are both gated on it. + sessionManagementEnabled: requiresSessionManagementMonitoring( + moduleName, + ), + ); + app_state.managersRx.update((managers) => managers..add(manager)); + app_state.currentManagerRx.$ = manager; + + logger.info('Initializing manager for test instance: $testInstanceId'); + // NOTE (merge with #469, test/467-per-module-conformance): this module's + // correct outcome is init() itself throwing -- the RP must stop after + // fetching discovery (OIDC Discovery §4.3) -- so it is the one module whose + // init failure is caught here instead of aborting the whole plan. + if (moduleName == discoveryIssuerMismatchModule) { + var rejected = false; + try { + await manager.init(); + logger.severe( + 'init() accepted a discovery document with a bad issuer', + ); + } on OidcException catch (e) { + // Only the issuer rejection counts; anything else (e.g. the discovery + // fetch failing) is a real failure and aborts as for other modules. + if (!e.message.contains('Issuer mismatch')) { + rethrow; + } + rejected = true; + logger.info('Rejected at discovery, as the module requires: $e'); + } + issuerMismatchRejected = rejected; + print('[e2e] $moduleName -> rejected at discovery: $rejected'); + if (rejected) { + final verdict = await pollConformanceModuleVerdict( + dio: dio, + instanceId: testInstanceId, + ); + await _recordModuleVerdict( + dio: dio, + instanceId: testInstanceId, + moduleFailures: moduleFailures, + logger: logger, + moduleName: moduleName, + verdict: verdict, + authDescription: 'rejected at discovery (expected)', + ); + await sub.cancel(); + app_state.currentManagerRx.$ = app_state.managersRx.$.first; + app_state.managersRx.update((managers) => managers..remove(manager)); + if (!kIsWeb && Platform.isLinux && !Platform.isAndroid) { + archive.addFile( + ArchiveFile.bytes( + '$moduleName.log', + utf8.encode(logsToWrite.join('\n')), + ), + ); + } + continue moduleLoop; + } + } else { + await manager.init(); + } + expect(manager.didInit, true); + logger.info('Manager initialized'); + if (moduleName == 'oidcc-client-test-discovery-openid-config') { + final verdict = await pollConformanceModuleVerdict( + dio: dio, + instanceId: testInstanceId, + ); + await _recordModuleVerdict( + dio: dio, + instanceId: testInstanceId, + moduleFailures: moduleFailures, + logger: logger, + moduleName: moduleName, + verdict: verdict, + authDescription: 'not driven (discovery-only module)', + ); + app_state.currentManagerRx.$ = app_state.managersRx.$.first; + app_state.managersRx.update((managers) => managers..remove(manager)); + await sub.cancel(); + continue moduleLoop; + } + // Recorded rather than discarded: swallowing the result here would let the + // suite pass whether or not the browser can capture a redirect at all. Not + // asserted per-module, since a negative module ends with no user by design. + // Which flow to drive is the module's decision, not ours: the suite states + // it in the variant, and the Basic/Config plans simply always say `code`. + // Hardcoding the code flow is why the hybrid and implicit plans could not + // be run at all -- every module would have been driven with the wrong + // response_type and failed for a reason that had nothing to do with the + // library. + final responseTypes = (variant['response_type'] as String? ?? 'code') + .split(' ') + .where((e) => e.isNotEmpty) + .toList(); + final hasCode = responseTypes.contains('code'); + final hasFrontChannelToken = + responseTypes.contains('id_token') || responseTypes.contains('token'); + final flowName = hasCode + ? (hasFrontChannelToken ? 'hybrid' : 'authorization code') + : 'implicit'; + logger.info( + 'Starting login $flowName flow (${responseTypes.join(' ')})...', + ); + // Extracted so [requiresSecondLoginForKeyRotation] modules can call it a + // SECOND time below: the suite rotates its signing key only once a second + // `authorize` request arrives, so without a second real interaction here + // the module waits forever for one the harness never made (#467). + // What the CLIENT saw, carried into the per-module failure line: on iOS + // that line is the only output that reaches the job log (see + // describeSuiteLogForFailure, api.dart). + String? loginError; + int? loginStartedAtMs; + final loginStopwatch = Stopwatch(); + // The native browser layer's own events (oidc_android / oidc_darwin; empty + // elsewhere), timed from the login start: they show whether the browser + // opened promptly, and whether the flow ended in a redirect or in + // flowTimeoutSeconds' cancel. + // + // Subscribed for the whole module rather than per attempt: the native + // event channel and the method reply that completes the login are + // separate channels, so the final event can land just after the login's + // Future does. It is cancelled once the verdict is recorded. + final browserTimeline = []; + var loginStartedAt = DateTime.now(); + final browserEvents = manager.events().listen((event) { + if (event is! OidcNativeBrowserEvent) { + return; + } + final offset = + event.at.difference(loginStartedAt).inMilliseconds / 1000; + browserTimeline.add( + '${describeNativeBrowserEvent(event)}@' + '${offset >= 0 ? '+' : ''}${offset.toStringAsFixed(2)}s', + ); + }); + Future attemptLogin() async { + browserTimeline.clear(); + loginError = null; + loginStartedAt = DateTime.now(); + loginStartedAtMs = loginStartedAt.millisecondsSinceEpoch; + loginStopwatch + ..reset() + ..start(); + try { + if (!hasCode) { + // No code comes back, so there is nothing to exchange. Deprecated in + // the library and by the OAuth Security BCP, but the Implicit RP + // profile is defined in terms of it. + // ignore: deprecated_member_use + return await manager.loginImplicitFlow(responseType: responseTypes); + } + if (hasFrontChannelToken) { + return await manager.loginHybridFlow(responseType: responseTypes); + } + return await manager.loginAuthorizationCodeFlow(); + } catch (e, stackTrace) { + // Expected for the negative modules, whose broken responses the client + // must reject, so record it rather than failing the run here. + logger.severe('Login flow threw for $moduleName', e, stackTrace); + loginError = '$e'; + return null; + } finally { + loginStopwatch.stop(); + } + } + + // CONFORMANCE_FORCE_STALL_MODULE (test-only, off by default): the first + // attempt of the first matching module never opens the browser, so the + // suite sees no authorization request: exactly what a stalled iOS + // browser leaves behind. It exists to prove the rerun path end to end + // in CI. + final forceStall = + attempt == 1 && + !_forcedStallUsed && + conformanceForceStallModule.isNotEmpty && + moduleName == conformanceForceStallModule; + final OidcUser? authResult; + if (forceStall) { + _forcedStallUsed = true; + loginStartedAtMs = DateTime.now().millisecondsSinceEpoch; + loginStopwatch.reset(); + loginError = + 'forced stall (CONFORMANCE_FORCE_STALL_MODULE=$moduleName): the ' + 'browser was never opened'; + print('[e2e] $moduleName -> $loginError'); + authResult = null; + } else { + authResult = await attemptLogin(); + } + if (authResult == null && attempt <= maxModuleReruns) { + // Did the browser reach the suite at all? Only the suite's own log can + // say, and only a "no" from it lets this instance be discarded (see + // shouldRerunModuleOnFreshInstance for why that cannot hide a verdict). + // The status first: a FINISHED/INTERRUPTED instance has a verdict + // and is never discarded, so its log need not even be read. + Map summary; + try { + summary = await getTestSummary(dio: dio, instanceId: testInstanceId); + } on Object catch (e) { + summary = {'pollError': '$e'}; + } + final suiteStatus = summary['status'] as String?; + final suiteLog = suiteStatus == 'WAITING' + ? await fetchTestLogs(dio: dio, instanceId: testInstanceId) + : const >[]; + if (shouldRerunModuleOnFreshInstance( + loggedIn: false, + attempt: attempt, + suiteStatus: suiteStatus, + suiteLog: suiteLog, + )) { + rerunNote = + 'RERUN: instance $testInstanceId (suite status=$suiteStatus, ' + 'result=${summary['result']}) was discarded because its suite ' + 'log shows NO authorization request arriving (client: no user ' + 'after ${loginStopwatch.elapsed.inMilliseconds}ms' + '${loginError == null ? '' : ', login threw: $loginError'}' + '; browser events [${browserTimeline.join(', ')}])'; + // print(), not logger: logger output is invisible in the iOS job log. + print( + '[e2e] $moduleName -> $rerunNote; running it once more on a ' + 'fresh instance', + ); + logger.warning(rerunNote); + try { + // Stop the abandoned instance rather than leave it WAITING on the + // suite. + await cancelTest(dio: dio, instanceId: testInstanceId); + } on Object catch (e) { + logger.warning('Could not stop instance $testInstanceId: $e'); + } + await browserEvents.cancel(); + await sub.cancel(); + app_state.currentManagerRx.$ = app_state.managersRx.$.first; + app_state.managersRx.update((managers) => managers..remove(manager)); + continue; + } + } + if (authResult != null) { + successfulLogins++; + // oidcc-client-test-signing-key-rotation (see + // requiresSecondLoginForKeyRotation, api.dart) only rotates its signing + // key, and only finishes, once it sees a SECOND full authorization + // interaction. package:oidc_core already self-heals a rotated key on + // its own (one rate-limited, cache-busting jwks refetch on a kid miss -- + // OIDC Core §10.1.1); the harness just has to actually issue the second + // login the module is waiting for. + if (requiresSecondLoginForKeyRotation(moduleName)) { + logger.info( + 'Signing-key-rotation module: issuing a second login to trigger ' + 'the key rotation and re-verification...', + ); + final secondAuthResult = await attemptLogin(); + logger.info( + secondAuthResult == null + ? 'Second login for $moduleName did not complete; the suite ' + 'verdict below will most likely be non-terminal.' + : 'Second login completed: ' + '${_describeToken(secondAuthResult.token)}', + ); + } + // oidcc-client-test-session-management (see + // requiresSessionManagementMonitoring, api.dart) will not finish unless + // the suite observes a check_session_iframe postMessage round trip + // BEFORE logout, and `listenToUserSessionIfSupported`'s automatic + // monitor runs on its own schedule (iframe load, then + // sessionManagementSettings.interval) -- calling logout() immediately + // after login, as every other module does, would very likely race it. + // Poll the suite's own log for its confirmation instead of guessing a + // sleep duration that either races the monitor or wastes every other + // module's time budget. + if (requiresSessionManagementMonitoring(moduleName)) { + logger.info( + 'Session management module: waiting for the suite to observe the ' + 'pre-logout check_session_iframe round trip...', + ); + final sawPreLogoutCheck = await waitForSuiteLogEntry( + dio: dio, + instanceId: testInstanceId, + matches: (entry) => + isSessionCheckPostMessageLogEntry(entry['msg'] as String?), + ); + logger.info( + sawPreLogoutCheck + ? 'Suite confirmed the pre-logout check_session_iframe round ' + 'trip.' + : 'Suite log never showed the pre-logout check_session_iframe ' + 'round trip within the wait budget; logging out anyway so ' + 'the suite verdict below names the real failure instead ' + 'of the harness hanging silently.', + ); + } + // The logout profiles are two-step: log in, THEN initiate logout, and the + // module only completes once it observes the end-session request. This + // harness drove the login and stopped, so every logout module sat waiting + // for a logout that never came, timed out at flowTimeoutSeconds, and + // reported no user -- four plans failing for one missing call, not four + // separate defects. + // + // postLogoutRedirectUri and frontChannelLogoutUri were already configured + // on the plan request, which is exactly why this looked wired. + if (isLogoutConformancePlan(planName)) { + logger.info('Logout profile: initiating RP-initiated logout...'); + try { + await manager.logout(); + successfulLogouts++; + logger.info('Logout completed.'); + } catch (e, stackTrace) { + // Some logout modules deliberately break the end-session response; + // record it rather than failing the whole plan here, matching how the + // login step treats its own negative modules. + logger.severe('Logout threw for $moduleName', e, stackTrace); + } + } + } + // print(), not logger: logger output goes into the certification archive + // rather than CI stdout. patrol also drops test stdout unless --verbose. + print( + '[e2e] $moduleName -> authResult ${authResult == null ? 'NULL' : 'ok'}', + ); + if (authResult == null) { + // "No user returned" is all the client can say, and it is not enough: a + // login that silently timed out and a negative module the client + // correctly rejected produce the identical line. The suite knows which + // happened -- ask it, rather than inferring from the client side. + // + // This is what the logout modules needed: each spent ~33s in + // loginAuthorizationCodeFlow and returned nothing, with no exception, so + // there was no way to tell whether the OP was waiting on the client or + // the client was waiting on the OP. + try { + final status = await getTestStatus( + dio: dio, + instanceId: testInstanceId, + ); + // Log the WHOLE payload. The first version of this read + // status['status'] and status['result'] -- key names invented rather + // than looked up -- and printed "status=null result=null" for every + // module. A diagnostic added to stop guessing that was itself a guess. + // Print what the endpoint actually returns, then read real keys off a + // real response. + logger.info('Suite status for $moduleName: $status'); + } on Object catch (e) { + logger.warning('Could not read suite status for $moduleName: $e'); + } + // `status` says WHETHER the suite issued a response; its log says WHY it + // did not. The harness already fetches this endpoint via monitorTestLogs + // and stops at "Setup Done", so every entry the suite wrote DURING the + // module was retrieved and discarded -- which is how 75 web fragment + // modules failed with nothing but a client-side timeout to go on. + // + // Tail only: the head is the setup chatter already seen, and the + // refusal, when there is one, is the last thing written. + final suiteLog = await fetchTestLogs( + dio: dio, + instanceId: testInstanceId, + ); + if (suiteLog.isEmpty) { + logger.info('Suite log for $moduleName: empty.'); + } else { + final tail = suiteLog.length <= 12 + ? suiteLog + : suiteLog.sublist(suiteLog.length - 12); + logger.info( + 'Suite log tail for $moduleName (${tail.length} of ' + '${suiteLog.length} entries):', + ); + for (final entry in tail) { + logger.info( + ' [${entry['result'] ?? '-'}] ${entry['msg']}' + '${entry['error'] == null ? '' : ' | error: ${entry['error']}'}', + ); + } + } + } + // Ask the suite itself whether THIS module is one it considers passed, + // regardless of what the client observed. authResult alone cannot tell a + // negative module that correctly saw no user from one that WRONGLY logged + // in -- successfulLogins only counts the latter case as a win -- and a + // module that is supposed to log in could still fail a suite-side check + // (e.g. a required requirement) after the client's own flow looked clean. + // See #467. + final verdict = await pollConformanceModuleVerdict( + dio: dio, + instanceId: testInstanceId, + ); + await _recordModuleVerdict( + dio: dio, + instanceId: testInstanceId, + moduleFailures: moduleFailures, + logger: logger, + moduleName: moduleName, + verdict: verdict, + clientLoginStartedAtMs: loginStartedAtMs, + authDescription: + '${authResult == null ? 'no user' : 'logged in'} after ' + '${loginStopwatch.elapsed.inMilliseconds}ms' + '${loginError == null ? '' : ', login threw: $loginError'}' + '; browser events [${browserTimeline.join(', ')}]' + '${rerunNote == null ? '' : '; $rerunNote'}', + ); + if (rerunNote != null) { + print( + '[e2e] $moduleName -> rerun on $testInstanceId: suite status=' + '${verdict['status']} result=${verdict['result']}', + ); + } + logger + ..info( + authResult == null + ? 'No user returned (expected for a negative module).' + : 'Login successful: ${_describeToken(authResult.token)}', + ) + ..info('Cleaning up manager for test instance: $testInstanceId'); + await browserEvents.cancel(); + await sub.cancel(); + app_state.currentManagerRx.$ = app_state.managersRx.$.first; + app_state.managersRx.update((managers) => managers..remove(manager)); + if (!kIsWeb && Platform.isLinux && !Platform.isAndroid) { + final strToWrite = logsToWrite.join('\n'); + final data = utf8.encode(strToWrite); + archive.addFile(ArchiveFile.bytes('$moduleName.log', data)); + } + break; + } + } + + expect( + issuerMismatchRejected, + isNot(false), + reason: + '$discoveryIssuerMismatchModule: init() must reject a discovery ' + 'document whose issuer does not match (OIDC Discovery §4.3).', + ); + + // Individual modules may legitimately end with no user, but a platform that + // cannot capture the browser redirect at all scores zero here. + print( + '[e2e] successful logins: $successfulLogins / ${testPlanModules.length}', + ); + expect( + successfulLogins, + greaterThan(0), + reason: + 'no module of $planName completed a login on ${getPlatformName()}. ' + 'Two very different causes produce this, and the per-module suite ' + 'status dumped above distinguishes them: either the redirect never ' + 'reached the app, or the suite aborted before issuing one (a module ' + 'whose status carries an error stack and no ' + 'authorization_endpoint_response_redirect never redirected at all). ' + 'Do not assume the former -- this message previously blamed the ' + "Android intent-filter, and the real cause was the suite's " + 'GenerateSessionState throwing on a host-less redirect_uri, on macOS ' + 'as much as on Android.', + ); + + // The per-module gate the aggregate above cannot be: successfulLogins only + // ever moves in the direction a negative module must NOT move, so a + // negative module that wrongly logs in stays invisible to it as long as any + // other module in the plan still passes. Asking the suite for each module's + // own result closes that gap (#467) -- including for the implicit-flow + // oidcc-client-test-missing-athash module that PR #447's CI let through. + expect( + moduleFailures, + isEmpty, + reason: + 'The conformance suite itself rejected these $planName modules on ' + '${getPlatformName()} (status/result read from GET api/info/{id}; ' + 'acceptable results are PASSED, WARNING, REVIEW, SKIPPED -- the same ' + 'set certification.openid.net itself accepts -- FAILED and a ' + 'non-terminal status after the poll timeout are not):\n' + '${moduleFailures.join('\n')}', + ); + + // The logout plans exist to exercise logout, so a login-only gate cannot + // report on them. Kept separate from the login gate above so a failure says + // which half broke. + if (isLogoutConformancePlan(planName)) { + print( + '[e2e] successful logouts: $successfulLogouts / ${testPlanModules.length}', + ); + expect( + successfulLogouts, + greaterThan(0), + reason: + 'every logout in $planName threw on ${getPlatformName()}, so the ' + 'plan proves nothing about logout even though its logins succeeded. ' + 'Check the end-session redirect: back-channel modules need a ' + 'backchannel_logout_uri the OP can reach, and front-channel modules ' + 'need a frontchannel_logout_uri this platform can actually serve.', + ); + } + + if (!kIsWeb && Platform.isLinux && !Platform.isAndroid) { + try { + print('Creating archive of client logs...'); + + final ms = OutputMemoryStream(); + ZipEncoder().encodeStream(archive, ms); + final bytes = ms.getBytes(); + print('Sending certification package request to server...'); + final resultLogs = await publishCertificationPackage( + dio: dio, + planId: testPlanId, + clientSideData: bytes, + ); + if (resultLogs == null) { + print('No Logs returned from server'); + } else { + var outputFile = File('client-logs/final.zip').absolute; + outputFile = await outputFile.create(recursive: true); + outputFile = await outputFile.writeAsBytes(resultLogs); + print('Saving logs archive at: ${outputFile.path}'); + } + } catch (e, stackTrace) { + _testLogger.severe( + 'Failed to publish certification package', + e, + stackTrace, + ); + print('failed to zip test logs: $e'); + } + } + print('OIDC Conformance Test completed'); + _testLogger.info('OIDC Conformance Test completed successfully.'); +} diff --git a/packages/oidc/example/integration_test/shared_offline.dart b/packages/oidc/example/integration_test/shared_offline.dart new file mode 100644 index 00000000..43621981 --- /dev/null +++ b/packages/oidc/example/integration_test/shared_offline.dart @@ -0,0 +1,279 @@ +// Harness-agnostic offline-mode e2e logic, shared by both runners: +// * `integration_test` (testWidgets) — used by the macOS CI job +// (`flutter test integration_test`). +// * Patrol (patrolTest) — used by android/iOS/linux/windows. +// +// The ONLY coupling to the test harness is a `launch` callback (start/settle +// the app) and a `pump` callback (advance one frame), so the exact same +// offline-mode assertions run everywhere. +// +// NOTE: this file imports `dart:io` (SocketException) and therefore must NOT be +// bundled for the web target — the web Patrol job runs `--target +// patrol_test/app_test.dart` only. + +import 'dart:collection'; +import 'dart:io'; + +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc/oidc.dart'; + +/// Starts the app (or pumps a placeholder widget) and settles it. Each runner +/// supplies its own implementation. +typedef LaunchApp = Future Function(); + +/// Advances the widget tree by one frame (`tester.pump()` / `$.pump()`). +typedef PumpFrame = Future Function(); + +const _issuer = 'https://test.example.com'; +const _audience = 'test-client'; +const _testSubject = 'user-123'; + +/// Signature verification is always-strict now; every id_token this file +/// mints is signed by this key, registered directly on the test manager's +/// keyStore (see [createManager]) so it verifies without needing a real +/// (mocked) `jwks_uri` fetch — these tests exercise OFFLINE / refresh +/// behaviour, not JWKS resolution. +final _signingKey = JsonWebKey.generate('RS256'); + +/// Asserts the manager enters offline mode after a refresh network failure and +/// keeps serving the cached token. +Future runOfflineEntersOfflineMode( + LaunchApp launch, + PumpFrame pump, +) async { + await launch(); + + final tokenQueue = Queue() + ..add(const SocketException('Network unreachable')); + final manager = createManager(tokenQueue: tokenQueue); + await manager.init(); + + final initialToken = _buildToken('initial'); + final user = await manager.seedUserWithToken(initialToken); + + final events = []; + final eventsSub = manager.events().listen(events.add); + + final refreshResult = await manager.refreshToken(); + await pump(); + + expect(refreshResult, same(user)); + expect(refreshResult?.token.accessToken, 'access-initial'); + expect(manager.isInOfflineMode, isTrue); + expect( + events.whereType().length, + greaterThanOrEqualTo(1), + ); + expect(events.whereType(), isEmpty); + + await eventsSub.cancel(); + await manager.dispose(); +} + +/// Asserts the manager exits offline mode once a later refresh succeeds. +Future runOfflineExitsAfterSuccess( + LaunchApp launch, + PumpFrame pump, +) async { + await launch(); + + final tokenQueue = Queue() + ..add(const SocketException('Temporary failure')) + ..add(_buildTokenResponse('refreshed')); + final manager = createManager(tokenQueue: tokenQueue); + await manager.init(); + + final initialToken = _buildToken('initial'); + await manager.seedUserWithToken(initialToken); + + final events = []; + final eventsSub = manager.events().listen(events.add); + + final firstAttempt = await manager.refreshToken(); + await pump(); + expect(firstAttempt, isNotNull); + expect(firstAttempt?.token.accessToken, 'access-initial'); + expect(manager.isInOfflineMode, isTrue); + + final secondAttempt = await manager.refreshToken(); + await pump(); + expect(secondAttempt, isNotNull); + expect(secondAttempt?.token.accessToken, 'access-refreshed'); + expect(manager.isInOfflineMode, isFalse); + + expect(events.whereType().length, 1); + expect(events.whereType().length, 1); + expect(manager.visibleConsecutiveRefreshFailures, 0); + + await eventsSub.cancel(); + await manager.dispose(); +} + +/// Asserts a repeat-refresh-failure warning is emitted once the configured +/// threshold is crossed. +Future runOfflineEmitsWarning(LaunchApp launch, PumpFrame pump) async { + await launch(); + + final tokenQueue = Queue() + ..add(const SocketException('failure-1')) + ..add(const SocketException('failure-2')); + final manager = createManager(tokenQueue: tokenQueue, warningThreshold: 2); + await manager.init(); + + final initialToken = _buildToken('initial'); + await manager.seedUserWithToken(initialToken); + + final events = []; + final eventsSub = manager.events().listen(events.add); + + await manager.refreshToken(); + await pump(); + expect(events.whereType(), isEmpty); + + await manager.refreshToken(); + await pump(); + final warnings = events.whereType().toList(); + expect( + warnings + .where( + (event) => + event.warningType == OfflineAuthWarningType.repeatRefreshFailure, + ) + .length, + greaterThanOrEqualTo(1), + ); + + await eventsSub.cancel(); + await manager.dispose(); +} + +OfflineTestUserManager createManager({ + required Queue tokenQueue, + int warningThreshold = 3, +}) { + final metadata = OidcProviderMetadata.fromJson({ + 'issuer': _issuer, + 'authorization_endpoint': '$_issuer/authorize', + 'token_endpoint': '$_issuer/token', + 'jwks_uri': '$_issuer/jwks', + 'response_types_supported': ['code'], + 'subject_types_supported': ['public'], + 'id_token_signing_alg_values_supported': ['RS256'], + 'token_endpoint_auth_methods_supported': ['none'], + 'grant_types_supported': ['authorization_code', 'refresh_token'], + }); + + final settings = OidcUserManagerSettings( + redirectUri: Uri.parse('com.example.test:/callback'), + scope: const ['openid', 'offline_access'], + supportOfflineAuth: true, + offlineRepeatFailureWarningThreshold: warningThreshold, + userInfoSettings: const OidcUserInfoSettings(sendUserInfoRequest: false), + refreshBefore: (_) => null, + hooks: OidcUserManagerHooks( + token: OidcHook( + modifyExecution: (request, defaultExecution) async { + if (tokenQueue.isEmpty) { + return defaultExecution(request); + } + final entry = tokenQueue.removeFirst(); + if (entry is OidcTokenResponse) { + return entry; + } + if (entry is Future) { + return entry; + } + if (entry is OidcTokenResponse Function(OidcTokenHookRequest)) { + return entry(request); + } + if (entry is Exception) { + throw entry; + } + if (entry is Error) { + throw entry; + } + throw StateError( + 'Unsupported token hook entry: ${entry.runtimeType}', + ); + }, + ), + ), + ); + + return OfflineTestUserManager( + discoveryDocument: metadata, + clientCredentials: const OidcClientAuthentication.none(clientId: _audience), + store: OidcMemoryStore(), + settings: settings, + keyStore: JsonWebKeyStore()..addKey(_signingKey), + ); +} + +OidcToken _buildToken(String label) { + final response = _buildTokenResponse(label); + return OidcToken.fromResponse(response, sessionState: 'session-$label'); +} + +OidcTokenResponse _buildTokenResponse(String label) { + final idToken = _createSignedIdToken(validity: const Duration(minutes: 10)); + return OidcTokenResponse.fromJson({ + 'access_token': 'access-$label', + 'refresh_token': 'refresh-$label', + 'token_type': 'Bearer', + 'expires_in': 600, + 'id_token': idToken, + }); +} + +String _createSignedIdToken({Duration validity = const Duration(minutes: 5)}) { + final now = clock.now(); + final builder = JsonWebSignatureBuilder() + ..jsonContent = { + 'iss': _issuer, + 'sub': _testSubject, + 'aud': _audience, + 'exp': (now.add(validity).millisecondsSinceEpoch ~/ 1000), + 'iat': (now.millisecondsSinceEpoch ~/ 1000), + } + ..addRecipient(_signingKey, algorithm: 'RS256'); + return builder.build().toCompactSerialization(); +} + +class OfflineTestUserManager extends OidcUserManager { + OfflineTestUserManager({ + required super.discoveryDocument, + required super.clientCredentials, + required super.store, + required super.settings, + super.httpClient, + super.keyStore, + super.id, + }); + + OfflineTestUserManager.lazy({ + required super.discoveryDocumentUri, + required super.clientCredentials, + required super.store, + required super.settings, + super.httpClient, + super.keyStore, + super.id, + }) : super.lazy(); + + int get visibleConsecutiveRefreshFailures => consecutiveRefreshFailures; + + Future seedUserWithToken(OidcToken token) async { + final user = await createUserFromToken( + token: token, + nonce: null, + attributes: null, + userInfo: null, + metadata: currentDiscoveryDocument!, + ); + if (user == null) { + throw StateError('Failed to seed user for tests'); + } + return user; + } +} diff --git a/packages/oidc/example/ios/Podfile b/packages/oidc/example/ios/Podfile index 279576f3..33b2217a 100644 --- a/packages/oidc/example/ios/Podfile +++ b/packages/oidc/example/ios/Podfile @@ -31,6 +31,12 @@ target 'Runner' do use_frameworks! use_modular_headers! + # Patrol's iOS native automation runs from a UI Testing Bundle embedded in the + # Runner target. `inherit! :complete` gives it the same pods as Runner. + target 'RunnerUITests' do + inherit! :complete + end + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/packages/oidc/example/ios/Runner.xcodeproj/project.pbxproj b/packages/oidc/example/ios/Runner.xcodeproj/project.pbxproj index 216bb72c..b6f277f5 100644 --- a/packages/oidc/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/oidc/example/ios/Runner.xcodeproj/project.pbxproj @@ -9,13 +9,25 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5D21C02FF6B5F227577D0EFA /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AD2315E0760B8E3847FDB91 /* RunnerUITests.m */; }; 6578EEA2C83B6388B886968C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D6986157739D9687AB6AFD6 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 86419E3D80B74DC9C6398C5C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EF823F3CE641AAAC8661D14 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 87225E9316D5111B2EB32B0C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -33,7 +45,10 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 1D6986157739D9687AB6AFD6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3AD2315E0760B8E3847FDB91 /* RunnerUITests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = RunnerUITests.m; path = RunnerUITests/RunnerUITests.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3EF823F3CE641AAAC8661D14 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 45B4EA64377B09818B39CCA4 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -58,6 +73,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0B572EA5B0DE865408F6057 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 86419E3D80B74DC9C6398C5C /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -65,6 +88,7 @@ isa = PBXGroup; children = ( 1D6986157739D9687AB6AFD6 /* Pods_Runner.framework */, + 9FB2FB4B30729EFBAD533BB9 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -99,6 +123,7 @@ 97C146EF1CF9000F007C117D /* Products */, 9567CFDD8F7714B0D403105A /* Pods */, 71A9A1607E036E391BA6A301 /* Frameworks */, + CBBAA90FC48701802E1310A5 /* RunnerUITests */, ); sourceTree = ""; }; @@ -106,6 +131,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 45B4EA64377B09818B39CCA4 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -125,6 +151,22 @@ path = Runner; sourceTree = ""; }; + 9FB2FB4B30729EFBAD533BB9 /* iOS */ = { + isa = PBXGroup; + children = ( + 3EF823F3CE641AAAC8661D14 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + CBBAA90FC48701802E1310A5 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + 3AD2315E0760B8E3847FDB91 /* RunnerUITests.m */, + ); + name = RunnerUITests; + sourceTree = SOURCE_ROOT; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -150,6 +192,26 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + 98FA3FEA03E033ADE7AA3E94 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33BD859DEC9E1866ACDDB6BC /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + 056FB6192D453DC281ABC6FB /* xcode_backend build */, + 35FC7FEB73820CEFD08FC9B3 /* Sources */, + C0B572EA5B0DE865408F6057 /* Frameworks */, + 0D03C9FCC81EDC3F2C0F743E /* Resources */, + 83DFB0921B548583573192F5 /* xcode_backend embed_and_thin */, + ); + buildRules = ( + ); + dependencies = ( + 644C038F5EBA434E82316D6D /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = 45B4EA64377B09818B39CCA4 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -163,6 +225,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + 98FA3FEA03E033ADE7AA3E94 = { + TestTargetID = 97C146ED1CF9000F007C117D; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -179,11 +244,19 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 98FA3FEA03E033ADE7AA3E94 /* RunnerUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 0D03C9FCC81EDC3F2C0F743E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -220,6 +293,24 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 056FB6192D453DC281ABC6FB /* xcode_backend build */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "xcode_backend build"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +344,24 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 83DFB0921B548583573192F5 /* xcode_backend embed_and_thin */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "xcode_backend embed_and_thin"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -271,6 +380,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 35FC7FEB73820CEFD08FC9B3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D21C02FF6B5F227577D0EFA /* RunnerUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -282,6 +399,15 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 644C038F5EBA434E82316D6D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 87225E9316D5111B2EB32B0C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -374,6 +500,28 @@ }; name = Profile; }; + 937FD8661BAF5BDEE6C3C091 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bdayadev.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -526,9 +674,62 @@ }; name = Release; }; + B96ACF6F03A7F9DA26F6A002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bdayadev.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + EF8CB0F4FD692ADCB25AB459 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.bdayadev.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 33BD859DEC9E1866ACDDB6BC /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B96ACF6F03A7F9DA26F6A002 /* Release */, + EF8CB0F4FD692ADCB25AB459 /* Debug */, + 937FD8661BAF5BDEE6C3C091 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/oidc/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/oidc/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d3..b45cfef9 100644 --- a/packages/oidc/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/oidc/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/packages/oidc/example/ios/RunnerUITests/RunnerUITests.m b/packages/oidc/example/ios/RunnerUITests/RunnerUITests.m new file mode 100644 index 00000000..d78d5be9 --- /dev/null +++ b/packages/oidc/example/ios/RunnerUITests/RunnerUITests.m @@ -0,0 +1,5 @@ +@import XCTest; +@import patrol_plus; +@import ObjectiveC.runtime; + +PATROL_INTEGRATION_TEST_IOS_RUNNER(RunnerUITests) diff --git a/packages/oidc/example/lib/access_token_usage_example.dart b/packages/oidc/example/lib/access_token_usage_example.dart index cb02ed6b..e54d99d3 100644 --- a/packages/oidc/example/lib/access_token_usage_example.dart +++ b/packages/oidc/example/lib/access_token_usage_example.dart @@ -19,9 +19,7 @@ void _tryAppendAccessToken( /// a Dio interceptor that automatically adds accessToken from the current user. class OidcUserManagerInterceptor extends Interceptor { - const OidcUserManagerInterceptor({ - required this.userManager, - }); + const OidcUserManagerInterceptor({required this.userManager}); final OidcUserManager userManager; @@ -34,10 +32,7 @@ class OidcUserManagerInterceptor extends Interceptor { /// Wraps an http client to automatically add the `Authorization` header. class OidcHttpClient extends BaseClient { - OidcHttpClient({ - required this.originalClient, - required this.userManager, - }); + OidcHttpClient({required this.originalClient, required this.userManager}); final Client originalClient; final OidcUserManager userManager; diff --git a/packages/oidc/example/lib/app_state.dart b/packages/oidc/example/lib/app_state.dart index 9da7e274..0dcc998d 100644 --- a/packages/oidc/example/lib/app_state.dart +++ b/packages/oidc/example/lib/app_state.dart @@ -1,47 +1,82 @@ // ignore_for_file: avoid_redundant_argument_values +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:async/async.dart'; import 'package:bdaya_shared_value/bdaya_shared_value.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http; import 'package:logging/logging.dart'; import 'package:oidc/oidc.dart'; import 'package:oidc_default_store/oidc_default_store.dart'; - import 'package:oidc_example/mock.dart'; +import 'package:oidc_example/web_redirect_uri.dart'; //This file represents a global state, which is bad //in a production app (since you can't test it). const kIsCI = bool.fromEnvironment('CI'); final http.Client client = kIsCI ? http.MockClient(ciHandler) : http.Client(); + +const _envOidcIssuer = String.fromEnvironment( + 'OIDC_ISSUER', + defaultValue: 'https://demo.duendesoftware.com', +); +const _envOidcClientId = String.fromEnvironment( + 'OIDC_CLIENT_ID', + defaultValue: 'interactive.public.short', +); +const _envOidcScopes = String.fromEnvironment( + 'OIDC_SCOPES', + defaultValue: 'openid,profile,email,offline_access', +); +// OIDC_REDIRECT_URI / OIDC_POST_LOGOUT_REDIRECT_URI live in +// web_redirect_uri.dart, so the conformance harness reads the same settings +// this app does and cannot register a redirect_uri the app will not send. + +List _parseScopes(String scopes) { + return scopes + .trim() + .split(RegExp(r'[\s,]+')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(growable: false); +} + +Uri _toAbsoluteWebUri( + String configuredUri, { + Map? queryParameters, +}) => resolveWebRedirectUri( + configuredUri, + base: Uri.base, + queryParameters: queryParameters, +); + Future ciHandler(http.Request request) async { // intercept requests to duende to avoid flaky tests. switch (request) { case http.Request( - method: 'GET', - url: Uri( - host: 'demo.duendesoftware.com', - pathSegments: [ - '.well-known', - 'openid-configuration', - ] - ) - ): + method: 'GET', + url: Uri( + host: 'demo.duendesoftware.com', + pathSegments: ['.well-known', 'openid-configuration'], + ), + ): return http.Response.bytes( utf8.encode(duendeDiscoveryDocument), HttpStatus.ok, ); default: - exampleLogger - .warning('sending out ${request.method} request to ${request.url}'); + exampleLogger.warning( + 'sending out ${request.method} request to ${request.url}', + ); final client = http.Client(); try { - return http.Response.fromStream(await client.send(request)); + return await http.Response.fromStream(await client.send(request)); } finally { client.close(); } @@ -53,58 +88,99 @@ Future ciHandler(http.Request request) async { final exampleLogger = Logger('oidc.example'); /// Gets the current manager used in the example. -OidcUserManager currentManager = duendeManager; +// OidcUserManager currentManager = duendeManager; +final currentManagerRx = SharedValue(value: duendeManager); +final managersRx = SharedValue>(value: [duendeManager]); +const duendeManagerId = 'duende'; final duendeManager = OidcUserManager.lazy( + id: duendeManagerId, discoveryDocumentUri: OidcUtils.getOpenIdConfigWellKnownUri( - Uri.parse('https://demo.duendesoftware.com'), + Uri.parse(_envOidcIssuer), ), // this is a public client, // so we use [OidcClientAuthentication.none] constructor. clientCredentials: const OidcClientAuthentication.none( - clientId: 'interactive.public.short', + clientId: _envOidcClientId, ), store: OidcDefaultStore(), httpClient: client, // keyStore: JsonWebKeyStore(), settings: OidcUserManagerSettings( - frontChannelLogoutUri: Uri(path: 'redirect.html'), + frontChannelLogoutUri: kIsWeb + ? _toAbsoluteWebUri( + 'redirect.html', + queryParameters: {OidcConstants_Store.managerId: duendeManagerId}, + ) + : Uri( + path: 'redirect.html', + queryParameters: {OidcConstants_Store.managerId: duendeManagerId}, + ), uiLocales: ['ar'], refreshBefore: (token) { - return const Duration(seconds: 1); + return const Duration(seconds: 5); }, - strictJwtVerification: true, // set to true to enable offline auth - supportOfflineAuth: false, - // scopes supported by the provider and needed by the client. - scope: ['openid', 'profile', 'email', 'offline_access'], + supportOfflineAuth: true, + // Scopes supported by the provider and needed by the client. + // Configure via: --dart-define=OIDC_SCOPES=openid,profile,email,offline_access + scope: _parseScopes(_envOidcScopes), postLogoutRedirectUri: kIsWeb - ? Uri.parse('http://localhost:22433/redirect.html') + ? _toAbsoluteWebUri(webPostLogoutRedirectUriSetting) : Platform.isAndroid || Platform.isIOS || Platform.isMacOS - ? Uri.parse('com.bdayadev.oidc.example:/endsessionredirect') - : Platform.isWindows || Platform.isLinux - ? Uri.parse('http://localhost:0') - : null, + ? Uri.parse('com.bdayadev.oidc.example:/endsessionredirect') + : Platform.isWindows || Platform.isLinux + ? Uri.parse('http://localhost:0') + : null, redirectUri: kIsWeb // this url must be an actual html page. // see the file in /web/redirect.html for an example. // - // for debugging in flutter, you must run this app with --web-port 22433 - ? Uri.parse('http://localhost:22433/redirect.html') + // Resolved against the origin the app is actually served from, so it + // follows --web-hostname/--web-port without being told either. + ? _toAbsoluteWebUri(webRedirectUriSetting) : Platform.isIOS || Platform.isMacOS || Platform.isAndroid - // scheme: reverse domain name notation of your package name. - // path: anything. - ? Uri.parse('com.bdayadev.oidc.example:/oauth2redirect') - : Platform.isWindows || Platform.isLinux - // using port 0 means that we don't care which port is used, - // and a random unused port will be assigned. - // - // this is safer than passing a port yourself. - // - // note that you can also pass a path like /redirect, - // but it's completely optional. - ? Uri.parse('http://localhost:0') - : Uri(), + // scheme: reverse domain name notation of your package name. + // path: anything. + ? Uri.parse('com.bdayadev.oidc.example:/oauth2redirect') + : Platform.isWindows || Platform.isLinux + // using port 0 means that we don't care which port is used, + // and a random unused port will be assigned. + // + // this is safer than passing a port yourself. + // + // note that you can also pass a path like /redirect, + // but it's completely optional. + ? Uri.parse('http://localhost:0') + : Uri(), + hooks: OidcUserManagerHooks( + token: OidcHookGroup( + hooks: [ + OidcHook( + modifyRequest: (request) { + exampleLogger.info( + 'Modifying token request: ${request.request.toMap()}', + ); + return Future.value(request); + }, + modifyResponse: (response) { + exampleLogger.info('Modifying token response: ${response.src}'); + return Future.value(response); + }, + ), + ], + executionHook: OidcHook( + modifyExecution: (request, defaultExecution) async { + exampleLogger.info( + 'Executing token request: ${request.request.toMap()}', + ); + final response = await defaultExecution(request); + exampleLogger.info('Executed token request: ${response.src}'); + return response; + }, + ), + ), + ), ), ); // final certificationManager = OidcUserManager.lazy( @@ -117,7 +193,6 @@ final duendeManager = OidcUserManager.lazy( // ), // store: OidcDefaultStore(), // settings: OidcUserManagerSettings( -// strictJwtVerification: true, // scope: ['profile', 'email'], // redirectUri: Uri.parse('http://localhost:22433/redirect.html'), // frontChannelLogoutUri: Uri.parse( @@ -138,14 +213,26 @@ final duendeManager = OidcUserManager.lazy( final initMemoizer = AsyncMemoizer(); Future initApp() { return initMemoizer.runOnce(() async { - currentManager.userChanges().listen((event) { - cachedAuthedUser.$ = event; - exampleLogger.info( - 'User changed: ${event?.claims.toJson()}, info: ${event?.userInfo}', - ); - }); + // Set up the secure storage for the default store. + try { + (duendeManager.store as OidcDefaultStore).secureStorage = + const FlutterSecureStorage(); + } catch (e) { + exampleLogger.severe('Failed to initialize secure storage', e); + } - await currentManager.init(); + // Follow the active manager's user stream, switching to the new manager's + // stream whenever the active manager changes (replaces rxdart's switchMap). + StreamSubscription? userChangesSub; + currentManagerRx.streamWithInitial.listen((manager) { + unawaited(userChangesSub?.cancel()); + userChangesSub = manager.userChanges().listen((event) { + cachedAuthedUser.$ = event; + exampleLogger.info( + 'User changed: ${event?.claims.toJson()}, info: ${event?.userInfo}', + ); + }); + }); }); } diff --git a/packages/oidc/example/lib/main.dart b/packages/oidc/example/lib/main.dart index 82101e42..2f5aa5ec 100644 --- a/packages/oidc/example/lib/main.dart +++ b/packages/oidc/example/lib/main.dart @@ -1,70 +1,194 @@ import 'package:bdaya_shared_value/bdaya_shared_value.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; -import 'package:oidc_core/oidc_core.dart'; +import 'package:oidc/oidc.dart'; import 'package:oidc_example/app_state.dart' as app_state; +import 'package:oidc_example/pages/add_manager_dialog.dart'; import 'package:oidc_example/pages/auth.dart'; import 'package:oidc_example/pages/home.dart'; import 'package:oidc_example/pages/secret_page.dart'; -// you must run this app with --web-port 22433 +// you must run this app with --web-port 22433 void main() { usePathUrlStrategy(); Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((record) { - debugPrint('${record.level.name}: ${record.time}: ${record.message}'); - }); + if (!app_state.kIsCI) { + Logger.root.onRecord.listen((record) { + debugPrint('${record.level.name}: ${record.time}: ${record.message}'); + }); + } runApp( SharedValue.wrapApp( MaterialApp.router( - theme: ThemeData( - useMaterial3: true, + debugShowCheckedModeBanner: false, + theme: ThemeData(useMaterial3: true), + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.trackpad, + }, ), routerConfig: GoRouter( routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomePage(), - ), - GoRoute( - path: '/secret-route', - redirect: (context, state) { - final user = app_state.cachedAuthedUser.of(context); - - if (user == null) { - return Uri( - path: '/auth', - queryParameters: { - // Note that this requires usePathUrlStrategy(); from `package:flutter_web_plugins/url_strategy.dart` - // and set - OidcConstants_Store.originalUri: state.uri.toString(), - }, - ).toString(); - } - return null; - }, - builder: (context, state) => const SecretPage(), - ), - GoRoute( - path: '/auth', - redirect: (context, state) { - // - final user = app_state.cachedAuthedUser.of(context); - if (user != null) { - final originalUri = state - .uri.queryParameters[OidcConstants_Store.originalUri]; - if (originalUri != null) { - return originalUri; - } - return '/secret-route'; - } - return null; + ShellRoute( + builder: (context, state, child) { + final selectedManager = app_state.currentManagerRx.of(context); + return FutureBuilder( + future: selectedManager.init(), + builder: (context, asyncSnapshot) { + return Scaffold( + appBar: AppBar( + title: const Text('OIDC Example App'), + actions: [ + if (asyncSnapshot.connectionState == + ConnectionState.waiting) + const CircularProgressIndicator(), + ], + ), + drawer: Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + ), + child: const Text( + 'OIDC Example', + style: TextStyle( + color: Colors.white, + fontSize: 24, + ), + ), + ), + const Divider(), + const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Select the manager', + style: TextStyle(fontSize: 16), + ), + ), + for (final manager in app_state.managersRx.of( + context, + )) ...[ + ListTile( + selected: manager == selectedManager, + leading: const Icon(Icons.account_circle), + title: Text(manager.id ?? ''), + onTap: () { + app_state.currentManagerRx.$ = manager; + }, + ), + ], + ListTile( + leading: const Icon(Icons.add), + title: const Text('Add a new manager'), + onTap: () async { + // This will add a new manager. + // Show the dialog + final newManager = + await showDialog( + context: context, + builder: (context) { + return const AddManagerDialog(); + }, + ); + if (newManager == null) { + return; + } + app_state.managersRx.update((managers) { + managers.add(newManager); + return managers; + }); + app_state.currentManagerRx.$ = newManager; + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.home), + title: const Text('Home'), + onTap: () { + context.go('/'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.lock), + title: const Text('Secret Page'), + onTap: () { + context.go('/secret-route'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.login), + title: const Text('Login'), + onTap: () { + context.go('/auth'); + Navigator.pop(context); + }, + ), + ], + ), + ), + body: + asyncSnapshot.connectionState == ConnectionState.done + ? child + : null, + ); + }, + ); }, - builder: (context, state) => const AuthPage(), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/secret-route', + redirect: (context, state) { + final user = app_state.cachedAuthedUser.of(context); + + if (user == null) { + return Uri( + path: '/auth', + queryParameters: { + // Note that this requires usePathUrlStrategy(); from `package:flutter_web_plugins/url_strategy.dart` + OidcConstants_Store.originalUri: state.uri.toString(), + }, + ).toString(); + } + return null; + }, + builder: (context, state) => const SecretPage(), + ), + GoRoute( + path: '/auth', + redirect: (context, state) { + // + final user = app_state.cachedAuthedUser.of(context); + if (user != null) { + final originalUri = state + .uri + .queryParameters[OidcConstants_Store.originalUri]; + if (originalUri != null) { + return originalUri; + } + return '/secret-route'; + } + return null; + }, + builder: (context, state) => const AuthPage(), + ), + ], ), ], ), @@ -80,9 +204,7 @@ void main() { ); } if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); + return Center(child: Text(snapshot.error.toString())); } return child!; }, diff --git a/packages/oidc/example/lib/pages/add_manager_dialog.dart b/packages/oidc/example/lib/pages/add_manager_dialog.dart new file mode 100644 index 00000000..f2e7bb1e --- /dev/null +++ b/packages/oidc/example/lib/pages/add_manager_dialog.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:oidc_example/app_state.dart' as app_state; + +class AddManagerDialog extends StatefulWidget { + const AddManagerDialog({super.key}); + + @override + State createState() => _AddManagerDialogState(); +} + +class _AddManagerDialogState extends State { + final _formKey = GlobalKey(); + final _managerIdController = TextEditingController(); + final _discoveryUriController = TextEditingController(); + final _clientIdController = TextEditingController(); + final _clientSecretController = TextEditingController(); + final _redirectUriController = TextEditingController(); + final _postLogoutRedirectUriController = TextEditingController(); + final _scopeController = TextEditingController(text: 'openid profile email'); + + OidcClientAuthenticationType _authType = OidcClientAuthenticationType.none; + bool _supportOfflineAuth = false; + + @override + void dispose() { + _managerIdController.dispose(); + _discoveryUriController.dispose(); + _clientIdController.dispose(); + _clientSecretController.dispose(); + _redirectUriController.dispose(); + _postLogoutRedirectUriController.dispose(); + _scopeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add New OIDC Manager'), + content: SizedBox( + width: double.maxFinite, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _managerIdController, + decoration: const InputDecoration( + labelText: 'Manager ID', + helperText: 'Unique identifier for this manager', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a manager ID'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _discoveryUriController, + decoration: const InputDecoration( + labelText: 'Discovery Document URI', + helperText: 'OIDC provider discovery endpoint', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a discovery URI'; + } + try { + Uri.parse(value); + } catch (e) { + return 'Please enter a valid URI'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _clientIdController, + decoration: const InputDecoration( + labelText: 'Client ID', + helperText: 'OAuth2/OIDC client identifier', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a client ID'; + } + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _authType, + decoration: const InputDecoration( + labelText: 'Client Authentication Type', + ), + items: const [ + DropdownMenuItem( + value: OidcClientAuthenticationType.none, + child: Text('None (Public Client)'), + ), + DropdownMenuItem( + value: OidcClientAuthenticationType.clientSecretPost, + child: Text('Client Secret (POST)'), + ), + DropdownMenuItem( + value: OidcClientAuthenticationType.clientSecretBasic, + child: Text('Client Secret (Basic)'), + ), + ], + onChanged: (value) { + setState(() { + _authType = value!; + }); + }, + ), + if (_authType != OidcClientAuthenticationType.none) ...[ + const SizedBox(height: 16), + TextFormField( + controller: _clientSecretController, + decoration: const InputDecoration( + labelText: 'Client Secret', + ), + obscureText: true, + validator: (value) { + if (_authType != OidcClientAuthenticationType.none && + (value == null || value.isEmpty)) { + return 'Please enter a client secret'; + } + return null; + }, + ), + ], + const SizedBox(height: 16), + TextFormField( + controller: _redirectUriController, + decoration: const InputDecoration( + labelText: 'Redirect URI', + helperText: 'URI to redirect after authentication', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a redirect URI'; + } + try { + Uri.parse(value); + } catch (e) { + return 'Please enter a valid URI'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _postLogoutRedirectUriController, + decoration: const InputDecoration( + labelText: 'Post Logout Redirect URI (Optional)', + helperText: 'URI to redirect after logout', + ), + validator: (value) { + if (value != null && value.isNotEmpty) { + try { + Uri.parse(value); + } catch (e) { + return 'Please enter a valid URI'; + } + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _scopeController, + decoration: const InputDecoration( + labelText: 'Scopes', + helperText: 'Space-separated list of scopes', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter at least one scope'; + } + return null; + }, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Support Offline Auth'), + subtitle: const Text('Allow authentication when offline'), + value: _supportOfflineAuth, + onChanged: (value) { + setState(() { + _supportOfflineAuth = value; + }); + }, + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _createManager, + child: const Text('Add Manager'), + ), + ], + ); + } + + void _createManager() { + if (!_formKey.currentState!.validate()) { + return; + } + + try { + final OidcClientAuthentication credentials; + switch (_authType) { + case OidcClientAuthenticationType.none: + credentials = OidcClientAuthentication.none( + clientId: _clientIdController.text, + ); + case OidcClientAuthenticationType.clientSecretPost: + credentials = OidcClientAuthentication.clientSecretPost( + clientId: _clientIdController.text, + clientSecret: _clientSecretController.text, + ); + case OidcClientAuthenticationType.clientSecretBasic: + credentials = OidcClientAuthentication.clientSecretBasic( + clientId: _clientIdController.text, + clientSecret: _clientSecretController.text, + ); + } + + final scopes = OidcInternalUtilities.splitSpaceDelimitedString( + _scopeController.text, + ); + + final manager = OidcUserManager.lazy( + id: _managerIdController.text, + discoveryDocumentUri: Uri.parse(_discoveryUriController.text), + clientCredentials: credentials, + store: OidcDefaultStore(), + httpClient: app_state.client, + settings: OidcUserManagerSettings( + scope: scopes, + supportOfflineAuth: _supportOfflineAuth, + redirectUri: Uri.parse(_redirectUriController.text), + postLogoutRedirectUri: _postLogoutRedirectUriController.text.isEmpty + ? null + : Uri.parse(_postLogoutRedirectUriController.text), + ), + ); + + Navigator.of(context).pop(manager); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Manager "${_managerIdController.text}" added successfully', + ), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error creating manager: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } +} + +enum OidcClientAuthenticationType { none, clientSecretPost, clientSecretBasic } diff --git a/packages/oidc/example/lib/pages/auth.dart b/packages/oidc/example/lib/pages/auth.dart index 2b741da6..1a580567 100644 --- a/packages/oidc/example/lib/pages/auth.dart +++ b/packages/oidc/example/lib/pages/auth.dart @@ -21,8 +21,7 @@ class _AuthPageState extends State { OidcPlatformSpecificOptions_Web_NavigationMode.newPage; bool allowInsecureConnections = false; - OidcAppAuthExternalUserAgent externalUserAgent = - OidcAppAuthExternalUserAgent.asWebAuthenticationSession; + bool prefersEphemeral = false; OidcPlatformSpecificOptions _getOptions() { return OidcPlatformSpecificOptions( @@ -31,15 +30,14 @@ class _AuthPageState extends State { popupHeight: 800, popupWidth: 730, ), - // these settings are from https://pub.dev/packages/flutter_appauth. - android: OidcPlatformSpecificOptions_AppAuth_Android( + android: OidcNativeOptionsAndroid( allowInsecureConnections: allowInsecureConnections, ), - ios: OidcPlatformSpecificOptions_AppAuth_IosMacos( - externalUserAgent: externalUserAgent, + ios: OidcNativeOptionsApple( + prefersEphemeralWebBrowserSession: prefersEphemeral, ), - macos: OidcPlatformSpecificOptions_AppAuth_IosMacos( - externalUserAgent: externalUserAgent, + macos: OidcNativeOptionsApple( + prefersEphemeralWebBrowserSession: prefersEphemeral, ), windows: const OidcPlatformSpecificOptions_Native(), ); @@ -48,15 +46,15 @@ class _AuthPageState extends State { @override Widget build(BuildContext context) { //remember, you can only enter this route if there is no user. + final manager = app_state.currentManagerRx.of(context); final currentRoute = GoRouterState.of(context); final originalUri = currentRoute.uri.queryParameters[OidcConstants_Store.originalUri]; - final parsedOriginalUri = - originalUri == null ? null : Uri.tryParse(originalUri); + final parsedOriginalUri = originalUri == null + ? null + : Uri.tryParse(originalUri); return Scaffold( - appBar: AppBar( - title: const Text('Auth page'), - ), + appBar: AppBar(title: const Text('Auth page')), body: Padding( padding: const EdgeInsets.all(8), child: ListView( @@ -64,22 +62,18 @@ class _AuthPageState extends State { const Text('Resource owner grant'), TextField( controller: userNameController, - decoration: const InputDecoration( - labelText: 'username', - ), + decoration: const InputDecoration(labelText: 'username'), ), TextField( controller: passwordController, - decoration: const InputDecoration( - labelText: 'password', - ), + decoration: const InputDecoration(labelText: 'password'), ), const SizedBox(height: 8), ElevatedButton( onPressed: () async { final messenger = ScaffoldMessenger.of(context); try { - final result = await app_state.currentManager.loginPassword( + final result = await manager.loginPassword( username: userNameController.text, password: passwordController.text, ); @@ -94,11 +88,7 @@ class _AuthPageState extends State { } catch (e) { app_state.exampleLogger.severe(e.toString()); messenger.showSnackBar( - const SnackBar( - content: Text( - 'loginPassword failed!', - ), - ), + const SnackBar(content: Text('loginPassword failed!')), ); } }, @@ -112,12 +102,7 @@ class _AuthPageState extends State { ), DropdownButton( items: OidcPlatformSpecificOptions_Web_NavigationMode.values - .map( - (e) => DropdownMenuItem( - value: e, - child: Text(e.name), - ), - ) + .map((e) => DropdownMenuItem(value: e, child: Text(e.name))) .toList(), value: webNavigationMode, onChanged: (value) { @@ -135,8 +120,7 @@ class _AuthPageState extends State { onPressed: () async { final messenger = ScaffoldMessenger.of(context); try { - final result = - await app_state.currentManager.loginAuthorizationCodeFlow( + final result = await manager.loginAuthorizationCodeFlow( originalUri: parsedOriginalUri ?? Uri.parse('/'), //store any arbitrary data, here we store the authorization //start time. @@ -177,7 +161,7 @@ class _AuthPageState extends State { final messenger = ScaffoldMessenger.of(context); // ignore: deprecated_member_use - final result = await app_state.currentManager.loginImplicitFlow( + final result = await manager.loginImplicitFlow( responseType: OidcConstants_AuthorizationEndpoint_ResponseType .idToken_Token, originalUri: parsedOriginalUri ?? Uri.parse('/'), diff --git a/packages/oidc/example/lib/pages/home.dart b/packages/oidc/example/lib/pages/home.dart index 48b3a5a8..5e9b60f8 100644 --- a/packages/oidc/example/lib/pages/home.dart +++ b/packages/oidc/example/lib/pages/home.dart @@ -13,26 +13,23 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Oidc Example')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () { - GoRouter.of(context).go('/auth'); - }, - child: const Text('Go to Auth Page'), - ), - ElevatedButton( - onPressed: () { - GoRouter.of(context).go('/secret-route'); - }, - child: const Text('Go to Secret page'), - ), - ], - ), + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () { + GoRouter.of(context).go('/auth'); + }, + child: const Text('Go to Auth Page'), + ), + ElevatedButton( + onPressed: () { + GoRouter.of(context).go('/secret-route'); + }, + child: const Text('Go to Protected Area'), + ), + ], ), ); } diff --git a/packages/oidc/example/lib/pages/managers_page.dart b/packages/oidc/example/lib/pages/managers_page.dart new file mode 100644 index 00000000..6574328b --- /dev/null +++ b/packages/oidc/example/lib/pages/managers_page.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:oidc/oidc.dart'; +import 'package:oidc_default_store/oidc_default_store.dart'; +import 'package:oidc_example/app_state.dart'; + +class ManagersPage extends StatefulWidget { + const ManagersPage({super.key}); + + @override + State createState() => _ManagersPageState(); +} + +class _ManagersPageState extends State { + // final List _managers = [duendeManager]; + final _formKey = GlobalKey(); + final _idController = TextEditingController(); + final _nameController = TextEditingController(); + final _discoveryUriController = TextEditingController(); + final _clientIdController = TextEditingController(); + final _redirectUriController = TextEditingController(); + final _scopesController = TextEditingController(); + + @override + void dispose() { + _idController.dispose(); + _nameController.dispose(); + _discoveryUriController.dispose(); + _clientIdController.dispose(); + _redirectUriController.dispose(); + _scopesController.dispose(); + super.dispose(); + } + + void _addManager() { + if (!_formKey.currentState!.validate()) return; + + try { + final manager = OidcUserManager.lazy( + id: _idController.text, + discoveryDocumentUri: Uri.parse(_discoveryUriController.text), + clientCredentials: OidcClientAuthentication.none( + clientId: _clientIdController.text, + ), + store: OidcDefaultStore(), + httpClient: client, + settings: OidcUserManagerSettings( + scope: OidcInternalUtilities.splitSpaceDelimitedString( + _scopesController.text, + ), + redirectUri: Uri.parse(_redirectUriController.text), + ), + ); + + managersRx.update((managers) => managers..add(manager)); + + _clearForm(); + Navigator.of(context).pop(); + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error creating manager: $e'))); + } + } + + void _clearForm() { + _nameController.clear(); + _discoveryUriController.clear(); + _clientIdController.clear(); + _redirectUriController.clear(); + _scopesController.clear(); + } + + void _removeManager(int index) { + managersRx.update((managers) => managers..removeAt(index)); + } + + void _selectManager(OidcUserManager manager) { + currentManagerRx.$ = manager; + Navigator.of(context).pop(); + } + + void _showAddManagerDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add Manager'), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _discoveryUriController, + decoration: const InputDecoration( + labelText: 'Discovery URI', + hintText: + 'https://example.com/.well-known/openid-configuration', + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + try { + Uri.parse(value!); + return null; + } catch (e) { + return 'Invalid URI'; + } + }, + ), + TextFormField( + controller: _clientIdController, + decoration: const InputDecoration(labelText: 'Client ID'), + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + TextFormField( + controller: _redirectUriController, + decoration: const InputDecoration( + labelText: 'Redirect URI', + hintText: 'http://localhost:22433/redirect.html', + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Required'; + try { + Uri.parse(value!); + return null; + } catch (e) { + return 'Invalid URI'; + } + }, + ), + TextFormField( + controller: _scopesController, + decoration: const InputDecoration( + labelText: 'Scopes', + hintText: 'openid profile email', + ), + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton(onPressed: _addManager, child: const Text('Add')), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final currentUserManager = currentManagerRx.of(context); + final managers = managersRx.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('OIDC Managers'), + actions: [ + IconButton( + onPressed: _showAddManagerDialog, + icon: const Icon(Icons.add), + ), + ], + ), + body: ListView.builder( + itemCount: managers.length, + itemBuilder: (context, index) { + final manager = managers[index]; + final isSelected = manager.id == currentUserManager.id; + final isDefault = manager == duendeManager; + + return Card( + color: isSelected + ? Theme.of(context).primaryColor.withValues(alpha: 0.1) + : null, + child: ListTile( + leading: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected ? Theme.of(context).primaryColor : null, + ), + title: Text( + isDefault + ? 'Duende Demo (Default)' + : 'Custom Manager ${index + 1}', + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : null, + ), + ), + subtitle: Text( + manager.clientCredentials.clientId, + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: isDefault + ? null + : IconButton( + onPressed: () => _removeManager(index), + icon: const Icon(Icons.delete), + color: Colors.red, + ), + onTap: () => _selectManager(manager), + ), + ); + }, + ), + ); + } +} diff --git a/packages/oidc/example/lib/pages/secret_page.dart b/packages/oidc/example/lib/pages/secret_page.dart index e70bb4ef..7a22c204 100644 --- a/packages/oidc/example/lib/pages/secret_page.dart +++ b/packages/oidc/example/lib/pages/secret_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -13,199 +14,652 @@ class SecretPage extends StatefulWidget { State createState() => _SecretPageState(); } -class _SecretPageState extends State { +class _SecretPageState extends State + with SingleTickerProviderStateMixin { OidcPlatformSpecificOptions_Web_NavigationMode webNavigationMode = OidcPlatformSpecificOptions_Web_NavigationMode.newPage; + + // Offline mode state + final List _eventLog = []; + String? _offlineModeReason; + StreamSubscription? _eventSubscription; + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _setupEventListener(); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + _tabController.dispose(); + super.dispose(); + } + + void _setupEventListener() { + final manager = app_state.currentManagerRx.$; + + // Listen to events + _eventSubscription = manager.events().listen((event) { + if (!mounted) return; + + setState(() { + final timestamp = event.at.toLocal(); + final timeStr = + '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + + if (event is OidcOfflineModeEnteredEvent) { + _offlineModeReason = event.reason.name; + + _eventLog.insert( + 0, + '[$timeStr] OFFLINE MODE ENTERED\n' + 'Reason: ${event.reason.name}\n' + 'Last sync: ${_formatLastSync(event.lastSuccessfulServerContact)}', + ); + } else if (event is OidcOfflineModeExitedEvent) { + _offlineModeReason = null; + + _eventLog.insert( + 0, + '[$timeStr] OFFLINE MODE EXITED\n' + 'Network restored: ${event.networkRestored}\n' + 'Synced at: ${_formatLastSync(event.lastSuccessfulServerContact)}', + ); + } else if (event is OidcOfflineAuthWarningEvent) { + _eventLog.insert( + 0, + '[$timeStr] WARNING\n' + 'Type: ${event.warningType.name}\n' + 'Message: ${event.message}', + ); + } else if (event is OidcTokenExpiredEvent) { + _eventLog.insert(0, '[$timeStr] Token Expired'); + } else if (event is OidcTokenExpiringEvent) { + _eventLog.insert(0, '[$timeStr] Token Expiring Soon'); + } + + // Keep only last 20 events + if (_eventLog.length > 20) { + _eventLog.removeRange(20, _eventLog.length); + } + }); + }); + } + + String _formatLastSync(DateTime? lastSync) { + if (lastSync == null) return 'Never'; + + final local = lastSync.toLocal(); + return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} ' + '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}:${local.second.toString().padLeft(2, '0')}'; + } + @override Widget build(BuildContext context) { + final manager = app_state.currentManagerRx.of(context); final user = app_state.cachedAuthedUser.of(context); if (user == null) { // put a guard here as well, just in case // the redirect doesn't fire up in time. return const SizedBox.shrink(); } - final platform = Theme.of(context).platform; - final mobilePlatforms = [ - TargetPlatform.android, - TargetPlatform.iOS, - TargetPlatform.fuchsia, - ]; + final isOffline = manager.isInOfflineMode; + return Scaffold( - body: Padding( - padding: const EdgeInsets.all(8), - child: SelectableRegion( - focusNode: FocusNode(), - selectionControls: kIsWeb - ? mobilePlatforms.contains(platform) - ? MaterialTextSelectionControls() - : DesktopTextSelectionControls() - : MaterialTextSelectionControls(), - child: ListView( - children: [ - Center( - child: Text( - 'You have entered a guarded route!', - style: Theme.of(context).textTheme.displayLarge, - ), - ), - ElevatedButton( - onPressed: () { - GoRouter.of(context).go('/'); - }, - child: const Text('back to home'), + appBar: AppBar( + title: const Text('Protected Area'), + backgroundColor: isOffline ? Colors.orange : null, + actions: [ + if (isOffline) + const Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.cloud_off, size: 20), + SizedBox(width: 8), + Text('Offline Mode'), + ], ), - const Divider(), - ElevatedButton( - onPressed: () async { - try { - final res = await app_state.currentManager - .loginAuthorizationCodeFlow( - // you can change scope too! - scopeOverride: [ - ...app_state.currentManager.settings.scope, - 'api', - ], - promptOverride: ['none'], - options: const OidcPlatformSpecificOptions( - web: OidcPlatformSpecificOptions_Web( - navigationMode: - OidcPlatformSpecificOptions_Web_NavigationMode - .hiddenIFrame, - ), + ), + IconButton( + icon: const Icon(Icons.home), + onPressed: () => GoRouter.of(context).go('/'), + tooltip: 'Back to Home', + ), + ], + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(icon: Icon(Icons.dashboard), text: 'Overview'), + Tab(icon: Icon(Icons.data_object), text: 'Token Data'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [_buildOverviewTab(manager, user), _buildTokenDataTab(user)], + ), + ); + } + + Widget _buildOverviewTab(OidcUserManager manager, OidcUser user) { + final isOffline = manager.isInOfflineMode; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Offline Status Card + Card( + color: isOffline ? Colors.red.shade50 : Colors.green.shade50, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isOffline ? Icons.cloud_off : Icons.cloud_done, + size: 32, + color: isOffline ? Colors.red : Colors.green, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isOffline ? 'OFFLINE MODE' : 'ONLINE', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: isOffline ? Colors.red : Colors.green, + fontWeight: FontWeight.bold, + ), + ), + if (_offlineModeReason != null) + Text( + 'Reason: $_offlineModeReason', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('silently authorized user! ${res?.uid}'), - ), - ); - } - } on OidcException catch (e) { - if (e.errorResponse != null) { - await app_state.currentManager.forgetUser(); - } - } catch (e, st) { - app_state.exampleLogger - .severe('Failed to silently authorize user', e, st); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to silently authorize user'), - ), - ); - } - } - }, - child: const Text('Reauthorize with prompt none'), - ), - const Divider(), - if (kIsWeb) ...[ - Text( - 'Logout Web Navigation Mode', - style: Theme.of(context).textTheme.headlineSmall, + ), + ], ), - DropdownButtonHideUnderline( - child: DropdownButton< - OidcPlatformSpecificOptions_Web_NavigationMode>( - items: OidcPlatformSpecificOptions_Web_NavigationMode.values - .map( - (e) => DropdownMenuItem( - value: e, - child: Text(e.name), - ), - ) - .toList(), - value: webNavigationMode, - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - webNavigationMode = value; - }); - }, + const Divider(), + ListTile( + dense: true, + leading: const Icon(Icons.sync), + title: const Text('Last Server Contact'), + subtitle: Text( + _formatLastSync(manager.lastSuccessfulServerContact), + ), + ), + ListTile( + dense: true, + leading: const Icon(Icons.settings), + title: const Text('Offline Auth Enabled'), + subtitle: Text( + manager.settings.supportOfflineAuth ? 'Yes' : 'No', ), ), ], - ElevatedButton( - onPressed: () async { - await app_state.currentManager.logout( - //after logout, go back to home - originalUri: Uri.parse('/'), - options: OidcPlatformSpecificOptions( - web: OidcPlatformSpecificOptions_Web( - navigationMode: webNavigationMode, + ), + ), + ), + const SizedBox(height: 16), + + // User Info Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.person, size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Information', + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + 'ID: ${user.uid}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), ), - ); - }, - child: const Text('Logout'), - ), - const Divider(), - Wrap( - spacing: 8, - children: [ - ElevatedButton( - onPressed: () async { - try { - final res = - await app_state.currentManager.refreshToken(); - if (res == null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'It is not possible to refresh the token.', + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Actions Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Actions', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + + // Token and Auth actions + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: () => _tryRefreshToken(manager), + icon: const Icon(Icons.refresh), + label: const Text('Manual Token Refresh'), + ), + ElevatedButton.icon( + onPressed: () async { + try { + final res = await manager.loginAuthorizationCodeFlow( + scopeOverride: [...manager.settings.scope, 'api'], + promptOverride: ['none'], + options: const OidcPlatformSpecificOptions( + web: OidcPlatformSpecificOptions_Web( + navigationMode: + OidcPlatformSpecificOptions_Web_NavigationMode + .hiddenIFrame, ), ), ); - return; - } - if (context.mounted) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Manually refreshed token!'), + SnackBar( + content: Text( + 'Silently authorized user! ${res?.uid}', + ), ), ); + } on OidcException catch (e) { + if (e.errorResponse != null) { + await manager.forgetUser(); + } + } catch (e, st) { + app_state.exampleLogger.severe( + 'Failed to silently authorize user', + e, + st, + ); } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'An error occurred trying to ' - 'refresh the token', + }, + icon: const Icon(Icons.login), + label: const Text('Silent Reauth'), + ), + ElevatedButton.icon( + onPressed: () async { + await manager.forgetUser(); + }, + icon: const Icon(Icons.delete_forever), + label: const Text('Forget User'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + ), + ), + ], + ), + + // Logout section with web navigation mode + if (kIsWeb) ...[ + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + Text( + 'Logout', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Web Navigation Mode', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + DropdownButton< + OidcPlatformSpecificOptions_Web_NavigationMode + >( + isExpanded: true, + items: + OidcPlatformSpecificOptions_Web_NavigationMode + .values + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.name), + ), + ) + .toList(), + value: webNavigationMode, + onChanged: (value) { + if (value == null) return; + setState(() { + webNavigationMode = value; + }); + }, + ), + ], + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: () async { + await manager.logout( + originalUri: Uri.parse('/'), + options: OidcPlatformSpecificOptions( + web: OidcPlatformSpecificOptions_Web( + navigationMode: webNavigationMode, ), ), ); - } - } - }, - child: const Text('Refresh token manually'), + }, + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + ], ), - ElevatedButton( + ] else ...[ + const SizedBox(height: 16), + ElevatedButton.icon( onPressed: () async { - await app_state.currentManager.forgetUser(); + await manager.logout(originalUri: Uri.parse('/')); }, - child: const Text('Forget User'), + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + foregroundColor: Colors.white, + ), ), ], - ), - const Divider(), - Text('user id: ${user.uid}'), - const Divider(), - Text('userInfo endpoint response: ${user.userInfo}'), - const Divider(), - Text('id token claims: ${user.claims.toJson()}'), - const Divider(), - Text('id token: ${user.idToken}'), - const Divider(), - Text('token: ${jsonEncode(user.token.toJson())}'), - ], + ], + ), ), ), + const SizedBox(height: 16), + + // Event Log (if offline auth is enabled) - at the bottom + if (manager.settings.supportOfflineAuth) ...[ + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Event Log', + style: Theme.of(context).textTheme.titleLarge, + ), + ElevatedButton.icon( + onPressed: () { + setState(_eventLog.clear); + }, + icon: const Icon(Icons.clear_all, size: 16), + label: const Text('Clear Log'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Container( + height: 300, + padding: const EdgeInsets.all(8), + child: _eventLog.isEmpty + ? const Center( + child: Text( + 'No events yet.\nTry testing token refresh.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + itemCount: _eventLog.length, + itemBuilder: (context, index) { + final log = _eventLog[index]; + final isError = log.contains( + 'OFFLINE MODE ENTERED', + ); + final isWarning = log.contains('WARNING'); + final isSuccess = log.contains( + 'OFFLINE MODE EXITED', + ); + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isError + ? Colors.red.shade50 + : isWarning + ? Colors.orange.shade50 + : isSuccess + ? Colors.green.shade50 + : Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + log, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ], + ); + } + + Widget _buildTokenDataTab(OidcUser user) { + final platform = Theme.of(context).platform; + final mobilePlatforms = [ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.fuchsia, + ]; + + return SelectableRegion( + focusNode: FocusNode(), + selectionControls: kIsWeb + ? mobilePlatforms.contains(platform) + ? MaterialTextSelectionControls() + : DesktopTextSelectionControls() + : MaterialTextSelectionControls(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildDataCard('User ID', user.uid ?? 'N/A'), + _buildDataCard('UserInfo Response', user.userInfo.toString()), + _buildDataCard('ID Token Claims', jsonEncode(user.claims.toJson())), + _buildDataCard('ID Token', user.idToken), + _buildDataCard('Token', jsonEncode(user.token.toJson())), + ], ), ); } + + Widget _buildDataCard(String title, String data) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + data, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ], + ), + ), + ); + } + + Future _tryRefreshToken(OidcUserManager manager) async { + final messenger = ScaffoldMessenger.of(context); + + // Track offline state before the call + final wasOffline = manager.isInOfflineMode; + + try { + final result = await manager.refreshToken(); + + // Check offline state after the call + final nowOffline = manager.isInOfflineMode; + + if (result != null) { + // Got a new token - successful refresh + if (wasOffline && !nowOffline) { + // Exited offline mode - network restored! + messenger.showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.cloud_done, color: Colors.white), + SizedBox(width: 12), + Text('Network restored - Token refreshed successfully'), + ], + ), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } else { + // Normal refresh while online + messenger.showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 12), + Text('Token refreshed successfully'), + ], + ), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } else if (!wasOffline && nowOffline) { + // Just entered offline mode - event listener already logged it + messenger.showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.cloud_off, color: Colors.white), + SizedBox(width: 12), + Expanded( + child: Text( + 'Network unavailable - Continuing in offline mode', + ), + ), + ], + ), + backgroundColor: Colors.orange, + duration: Duration(seconds: 3), + ), + ); + } else { + // result == null, but no offline mode change + // Either: no refresh token, or already offline + if (!nowOffline) { + messenger.showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.info, color: Colors.white), + SizedBox(width: 12), + Text('No refresh token available'), + ], + ), + backgroundColor: Colors.orange, + duration: Duration(seconds: 3), + ), + ); + } + // If already offline, no snackbar - the event log shows activity + } + } catch (e) { + // Only genuine errors (non-network) reach here when offline auth is enabled + messenger.showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text('Refresh failed: $e')), + ], + ), + backgroundColor: Colors.red, + ), + ); + } + } } diff --git a/packages/oidc/example/lib/web_redirect_uri.dart b/packages/oidc/example/lib/web_redirect_uri.dart new file mode 100644 index 00000000..808192f2 --- /dev/null +++ b/packages/oidc/example/lib/web_redirect_uri.dart @@ -0,0 +1,115 @@ +/// The one place a web redirect URI is derived. +/// +/// The app under test and the conformance harness both need the redirect URI +/// the app will actually send. They used to build it separately -- the app +/// resolved against [Uri.base], the harness returned a hardcoded +/// `http://localhost:22433/redirect.html` -- and agreed only because CI served +/// the app at exactly that origin. A registered redirect_uri that does not +/// match the one the app sends is rejected by the OP as unregistered, so the +/// two must be one derivation, not two that happen to coincide. +library; + +/// The redirect page, overridable at build time. +/// +/// Read by both derivations below so a `--dart-define` cannot move one without +/// the other. +const webRedirectUriSetting = String.fromEnvironment( + 'OIDC_REDIRECT_URI', + defaultValue: 'redirect.html', +); + +/// The post-logout redirect page, overridable at build time. +const webPostLogoutRedirectUriSetting = String.fromEnvironment( + 'OIDC_POST_LOGOUT_REDIRECT_URI', + defaultValue: 'redirect.html', +); + +/// Resolves [configuredUri] against the origin the app is served from. +/// +/// [base] is the page URL -- pass [Uri.base] in the app and the harness; tests +/// pass a synthetic origin. Taking it as a parameter rather than reading the +/// global is what makes the resolution assertable at all. +/// +/// A relative [configuredUri] resolves against the current *directory*, so a +/// deep SPA route (`/app/secret-route`) still points at `/app/redirect.html` +/// rather than `/app/secret-route/redirect.html`. A leading `/` resolves +/// against the origin, and an absolute URI is returned as given. +Uri resolveWebRedirectUri( + String configuredUri, { + required Uri base, + Map? queryParameters, +}) { + final trimmed = configuredUri.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value( + configuredUri, + 'configuredUri', + 'must not be empty', + ); + } + + if (!base.isScheme('http') && !base.isScheme('https')) { + // Uri.origin throws a bare StateError here, naming neither this function + // nor the caller. Every non-web platform has a file:// Uri.base, so the + // wrong caller is a realistic mistake and its failure has to be readable. + throw ArgumentError.value( + base, + 'base', + 'must be an http or https origin; a web redirect URI cannot be resolved ' + 'against a ${base.scheme}: base. On non-web platforms use the ' + 'platform redirect URI instead of deriving one from Uri.base', + ); + } + + final origin = Uri.parse(base.origin); + + // Example: + // - /oidc-example/secret-route -> /oidc-example/ + // - /oidc-example/ -> /oidc-example/ + final basePath = base.path; + final directoryPath = basePath.endsWith('/') + ? basePath + : basePath.substring(0, basePath.lastIndexOf('/') + 1); + + final parsed = Uri.parse(trimmed); + + final Uri result; + if (parsed.hasScheme) { + result = parsed; + } else if (trimmed.startsWith('/')) { + result = origin.replace(path: parsed.path); + } else { + result = origin.replace(path: '$directoryPath${parsed.path}'); + } + + final mergedQueryParameters = { + ...result.queryParameters, + ...parsed.queryParameters, + if (queryParameters != null) ...queryParameters, + }; + + return result.replace( + queryParameters: mergedQueryParameters.isEmpty + ? null + : mergedQueryParameters, + ); +} + +/// The redirect URI the app under test registers and sends on web. +Uri appWebRedirectUri({required Uri base}) => + resolveWebRedirectUri(webRedirectUriSetting, base: base); + +/// The redirect URI the conformance harness registers with the test plan. +/// +/// Identical to [appWebRedirectUri] by construction. It exists as its own name +/// so the intent -- these two must never diverge -- is visible at both call +/// sites and lockable by a test. +Uri harnessWebRedirectUri({required Uri base}) => appWebRedirectUri(base: base); + +/// The front-channel logout URI: the redirect page with the marker query the +/// example's `redirect.html` dispatches on. +Uri webFrontChannelLogoutUri({required Uri base}) => resolveWebRedirectUri( + webRedirectUriSetting, + base: base, + queryParameters: const {'requestType': 'front-channel-logout'}, +); diff --git a/packages/oidc/example/linux/flutter/generated_plugins.cmake b/packages/oidc/example/linux/flutter/generated_plugins.cmake index 419dc799..2896cfd1 100644 --- a/packages/oidc/example/linux/flutter/generated_plugins.cmake +++ b/packages/oidc/example/linux/flutter/generated_plugins.cmake @@ -5,11 +5,13 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux oidc_linux + patrol_plus url_launcher_linux window_to_front ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/oidc/example/macos/Podfile b/packages/oidc/example/macos/Podfile index 049abe29..e546a5d9 100644 --- a/packages/oidc/example/macos/Podfile +++ b/packages/oidc/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -36,5 +36,25 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.15' + end + + next unless target.respond_to?(:source_build_phase) + + source_phase = target.source_build_phase + next unless source_phase + + xcprivacy_files = source_phase.files.select do |build_file| + file_ref = build_file.file_ref + file_ref && file_ref.path&.end_with?('.xcprivacy') + end + + next if xcprivacy_files.empty? + + xcprivacy_files.each do |build_file| + source_phase.remove_build_file(build_file) + end end end diff --git a/packages/oidc/example/macos/Runner.xcodeproj/project.pbxproj b/packages/oidc/example/macos/Runner.xcodeproj/project.pbxproj index 9d02f035..5f3cd326 100644 --- a/packages/oidc/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/oidc/example/macos/Runner.xcodeproj/project.pbxproj @@ -405,7 +405,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -484,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -531,7 +531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/packages/oidc/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/oidc/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5b055a3a..12076c83 100644 --- a/packages/oidc/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/oidc/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/packages/oidc/example/macos/Runner/AppDelegate.swift b/packages/oidc/example/macos/Runner/AppDelegate.swift index 8e02df28..b3c17614 100644 --- a/packages/oidc/example/macos/Runner/AppDelegate.swift +++ b/packages/oidc/example/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/oidc/example/patrol_test/app_test.dart b/packages/oidc/example/patrol_test/app_test.dart new file mode 100644 index 00000000..8532b50a --- /dev/null +++ b/packages/oidc/example/patrol_test/app_test.dart @@ -0,0 +1,271 @@ +// Patrol entrypoint (patrolTest) — used by the android/iOS/web/linux/windows CI +// jobs. macOS runs the same shared flow via `flutter test integration_test`. +// On web, Patrol drives Chromium via Playwright (no flutter-drive/DWDS), which +// avoids Flutter's DWDS startup race (flutter/flutter#181357). +// +// IMPORTANT: Patrol already bootstraps the app (runs `main` via `$dartRunMain`), +// so we must NOT call the example's main()/runApp here (that double-initializes +// the Flutter engine -> "engine has already started initialization"). Instead we +// pump a minimal widget via the PatrolTester. The OIDC conformance flow is +// programmatic (HTTP + OidcUserManager, whose web redirect rides on the engine's +// browser plugins, not the widget tree), so a placeholder widget is sufficient. +// +// The placeholder MUST be wrapped with SharedValue.wrapApp(): on android/iOS the +// native `$dartRunMain` runs the real main() (which already wraps), but the +// linux/windows desktop backend does NOT run the real main(), so without this +// the conformance flow throws "SharedValue was not initalized" at the first +// app_state.*Rx update (shared_e2e.dart:193). Logic is shared in +// ../integration_test/shared_e2e.dart, so the Patrol and flutter-test harnesses +// run identical tests. +// +// CONFORMANCE_SHARD (CI sharding): the android/iOS/linux/windows CI jobs run +// every `patrol_test/*_test.dart` file in one `patrol test` invocation, so the +// 13 conformance plans below used to run sequentially in a single job -- the +// Hybrid RP plan alone takes ~262s, and all 13 together take ~13-14 minutes. +// `--dart-define=CONFORMANCE_SHARD=[,...]` lets a CI matrix split +// them across parallel jobs instead. Every call below goes through +// [_registerPlan], which tags the plan with one of [_knownShards]; a job that +// doesn't shard passes nothing and gets the 'all' default, which registers +// every plan exactly as before sharding existed. +// +// The value is a COMMA-SEPARATED SET of tags, not a single tag: android, +// linux and windows draw from GitHub's large general-purpose runner pool and +// shard 4 ways (one tag each). iOS shares Apple's much smaller "concurrent +// macOS jobs" pool (5 on most plans -- see +// https://docs.github.com/en/actions/reference/limits, "Usage limits") with +// the macOS job, so it shards only 2 ways, each requesting two tags at once +// (e.g. "hybrid,implicit"), to keep this workflow's own macOS-pool demand low +// enough that its shards don't queue behind each other. +// +// A skipped/absent plan must never look like a pass, so an unknown shard tag +// or an unknown --dart-define value throws immediately, and a requested set +// that selects zero plans fails loudly at the end of main() instead of +// letting `patrol test` report a quiet, empty green. + +import 'package:bdaya_shared_value/bdaya_shared_value.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:patrol_plus/patrol.dart'; + +import '../integration_test/shared_e2e.dart'; + +Future _launch(PatrolIntegrationTester $) async { + // Mirror the part of example main() the OIDC flow relies on, without + // re-running runApp (Patrol already bootstrapped the engine). wrapApp() sets + // the static SharedValue.didWrap flag and installs the StateManagerWidget, so + // the conformance flow's app_state.managersRx/currentManagerRx updates work on + // the desktop backend too. Idempotent where the real main() already wrapped. + usePathUrlStrategy(); + await $.pumpWidgetAndSettle( + SharedValue.wrapApp( + const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: Text('OIDC conformance harness'))), + ), + ), + ); +} + +/// The raw value of `--dart-define=CONFORMANCE_SHARD=[,...]`. See +/// the file doc comment above for why this exists and its syntax. +const String _requestedShard = String.fromEnvironment( + 'CONFORMANCE_SHARD', + defaultValue: 'all', +); + +/// Every shard tag a CI matrix is allowed to request, besides the 'all' +/// (unsharded) default. +const Set _knownShards = {'basic', 'implicit', 'hybrid', 'rest'}; + +/// [_requestedShard] split on commas, or null for the 'all' (unsharded) +/// default. Validated once at the top of [main]: every element must be a +/// known shard tag, so a typo'd --dart-define value fails immediately rather +/// than silently matching nothing. +final Set? _requestedShardSet = _requestedShard == 'all' + ? null + : _requestedShard.split(',').map((s) => s.trim()).toSet(); + +/// How many plans [_requestedShardSet] selected so far. Checked at the end of +/// [main] -- see [_registerPlan]. +int _selectedPlanCount = 0; + +/// Registers conformance plan [name] (tagged with [shard]) as a patrolTest. +/// +/// Every conformance patrolTest call in [main] MUST go through this function +/// instead of calling `patrolTest` directly, so a plan can never silently end +/// up assigned to no shard. An unrecognized [shard] throws immediately (a +/// typo at the call site), which fails the whole process loudly rather than +/// quietly dropping a plan from every CI job that shards. +void _registerPlan( + String shard, + String name, + Future Function(PatrolIntegrationTester $) body, +) { + if (!_knownShards.contains(shard)) { + throw StateError( + 'Conformance plan "$name" is tagged with unknown shard "$shard" - ' + 'expected one of $_knownShards.', + ); + } + if (_requestedShardSet != null && !_requestedShardSet!.contains(shard)) { + return; + } + _selectedPlanCount++; + patrolTest(name, body); +} + +void main() { + ensureLoggingConfigured(); + + if (_requestedShardSet != null && + _requestedShardSet!.any((tag) => !_knownShards.contains(tag))) { + throw StateError( + 'Unknown CONFORMANCE_SHARD "$_requestedShard" - expected "all" or a ' + 'comma-separated set drawn from $_knownShards.', + ); + } + + if (oidcConformanceToken.isEmpty) { + // Unaffected by CONFORMANCE_SHARD: every shard job still proves it can + // start the app when no live conformance run is possible (e.g. a + // dependabot PR, which gets a secret store without + // OIDC_CONFORMANCE_TOKEN). Running this in every shard is cheap (it is a + // single smoke test) and keeps that guarantee regardless of which shard + // a given job was assigned. + patrolTest('Simple manager initializes correctly', ($) async { + await runManagerSmokeTest(() => _launch($)); + }); + } else { + _registerPlan('basic', 'OIDC Conformance: Basic RP', ($) async { + await runOidcConformanceTest(() => _launch($)); + }); + + // Config RP exercises the OP configuration the README lists as implemented + // (OpenID Connect Discovery). Only the Basic plan was ever run, so that + // claim has never been verified by the suite. A separate test so a Config + // failure names itself instead of surfacing as a Basic regression. + // Hybrid and Implicit exercise response types the Basic/Config plans never + // request. The flow is chosen per-module from the suite's own variant, so + // these need no special driving -- only the plan id. + _registerPlan('hybrid', 'OIDC Conformance: Hybrid RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-hybrid-certification-test-plan', + ); + }); + + _registerPlan('implicit', 'OIDC Conformance: Implicit RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-implicit-certification-test-plan', + ); + }); + + // The four logout profiles, discovered from the suite's own api/plan/available + // rather than guessed. Note the shape: they do NOT use the + // `-certification-test-plan` suffix the other profiles use, which is why no + // amount of searching produced them and why every guessed id would have + // 400ed. Each is pinned to the `-rp-basic` variant, matching the profile + // this library is certified against. + _registerPlan('rest', 'OIDC Conformance: RP-Initiated Logout', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-rp-initiated-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Front-Channel Logout', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-front-channel-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Back-Channel Logout', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-back-channel-logout-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Session Management', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-rp-session-management-rp-basic', + clientAuthType: 'client_secret_basic', + ); + }); + + // Form Post and 3rd-party-init, from the suite's published list. Variant + // rules are unknown for these; they omit clientAuthType, matching the + // plans they most resemble. A wrong choice is named by the plan-creation + // diagnostic and pinned in api.dart, not rediscovered. + _registerPlan('rest', 'OIDC Conformance: Form Post Basic RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-formpost-basic-certification-test-plan', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Form Post Hybrid RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-formpost-hybrid-certification-test-plan', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Form Post Implicit RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-formpost-implicit-certification-test-plan', + ); + }); + + _registerPlan('rest', 'OIDC Conformance: 3rd Party-Init Login RP', ( + $, + ) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-test-3rd-party-init-login-test-plan', + extraPlanVariant: {'response_type': 'code'}, + clientAuthType: 'client_secret_basic', + ); + }); + + // Dynamic RP: the client registers itself at the OP rather than being + // pre-provisioned, so this is the one plan that does NOT take + // static_client. `dynamic_client` is the documented counterpart already + // named in the harness's own variant comment, not a guess at a new value. + _registerPlan('rest', 'OIDC Conformance: Dynamic RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-dynamic-certification-test-plan', + clientRegistration: null, + clientAuthType: 'client_secret_basic', + requestType: null, + ); + }); + + _registerPlan('rest', 'OIDC Conformance: Config RP', ($) async { + await runOidcConformanceTest( + () => _launch($), + planName: 'oidcc-client-config-certification-test-plan', + // The discovery module rejects the plan outright without this; the + // Basic plan defaults it, this one does not. + clientAuthType: 'client_secret_basic', + ); + }); + + if (_requestedShard != 'all' && _selectedPlanCount == 0) { + throw StateError( + 'CONFORMANCE_SHARD "$_requestedShard" selected zero conformance ' + 'plans out of 13 known plans - check the shard tag on each ' + '_registerPlan call above. A skipped/absent plan must never look ' + 'like a pass.', + ); + } + } +} diff --git a/packages/oidc/example/patrol_test/offline_mode_test.dart b/packages/oidc/example/patrol_test/offline_mode_test.dart new file mode 100644 index 00000000..a4b793ca --- /dev/null +++ b/packages/oidc/example/patrol_test/offline_mode_test.dart @@ -0,0 +1,42 @@ +// Patrol entrypoint (patrolTest) for the offline-mode e2e flow — used by the +// android/iOS/linux/windows CI jobs (`patrol test --coverage`). The macOS job +// runs the same assertions through integration_test via +// ../integration_test/offline_mode_test.dart. Shared logic lives in +// ../integration_test/shared_offline.dart, so both harnesses run identical +// tests. +// +// IMPORTANT: Patrol already bootstraps the app (runs `main` via +// `$dartRunMain`), so we must NOT call the example's main()/runApp here. The +// offline-mode flow is programmatic (it drives OidcUserManager directly with an +// in-memory store), so pumping a minimal placeholder widget is sufficient. +// +// This entrypoint is intentionally NOT run on web: shared_offline.dart imports +// `dart:io`, and the web Patrol job targets only patrol_test/app_test.dart. + +import 'package:flutter/material.dart'; +import 'package:patrol_plus/patrol.dart'; + +import '../integration_test/shared_offline.dart'; + +Future _launch(PatrolIntegrationTester $) async { + await $.pumpWidgetAndSettle( + const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: Text('OIDC offline-mode harness'))), + ), + ); +} + +void main() { + patrolTest('enters offline mode after refresh failure', ($) async { + await runOfflineEntersOfflineMode(() => _launch($), $.pump); + }); + + patrolTest('exits offline mode after subsequent success', ($) async { + await runOfflineExitsAfterSuccess(() => _launch($), $.pump); + }); + + patrolTest('emits warning after repeated refresh failures', ($) async { + await runOfflineEmitsWarning(() => _launch($), $.pump); + }); +} diff --git a/packages/oidc/example/pubspec.yaml b/packages/oidc/example/pubspec.yaml index 819ffbc3..5f791a35 100644 --- a/packages/oidc/example/pubspec.yaml +++ b/packages/oidc/example/pubspec.yaml @@ -2,34 +2,60 @@ name: oidc_example description: Demonstrates how to use the oidc plugin. version: 0.1.0+1 publish_to: none - +resolution: workspace environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.9.0 <4.0.0" dependencies: async: ^2.11.0 + bdaya_shared_value: ^5.0.0 + clock: ^1.1.2 + dio: ^5.3.3 flutter: sdk: flutter + flutter_secure_storage: any flutter_web_plugins: sdk: flutter - bdaya_shared_value: ^3.1.3 - dio: ^5.3.3 - go_router: ^14.1.4 + go_router: ^17.0.1 http: ^1.1.0 logging: ^1.2.0 oidc: path: ../ - oidc_core: ^0.12.0 - oidc_default_store: ^0.2.0+15 - + oidc_core: ^4.0.0 + oidc_default_store: ^2.0.0 dev_dependencies: - flutter_driver: - sdk: flutter + archive: ^4.0.7 + # integration_test + flutter_test back the macOS e2e job only + # (`flutter test integration_test`); Patrol coverage is unsupported on macOS. + # Every other platform (android/iOS/web/linux/windows) runs via patrol_plus + # below. flutter_driver is no longer needed: web moved off flutter-drive/DWDS + # to the Patrol (Playwright) web runner. flutter_test: sdk: flutter integration_test: sdk: flutter - very_good_analysis: ^6.0.0 + # patrol_plus: published fork of Patrol (pub.dev) that adds desktop + # (linux/windows) test backends and a Playwright-based web runner. The + # matching CLI, patrol_cli_plus, is activated globally in CI. Web tests run + # via Playwright (no flutter-drive/DWDS), which sidesteps Flutter's DWDS + # startup race (flutter/flutter#181357). + patrol_plus: ^5.6.1 # >=5.6.1 for the Xcode-26 iOS CI fixes (terminate suppression) + very_good_analysis: ^10.0.0 + +# Patrol CLI config (patrol_cli_plus is activated globally in CI, not depended +# on here, so its deps don't enter the app's resolution). Tests live in the +# default `patrol_test/` directory. +# The key is `patrol_plus`, not `patrol`. patrol_cli_plus reads +# `yaml['patrol_plus']` and falls back to its default config when absent, so the +# legacy key silently discards this block along with the uninstall finalizers. +patrol_plus: + app_name: OIDC Example + android: + package_name: com.bdayadev.oidc.example + ios: + bundle_id: com.bdayadev.oidc.example + macos: + bundle_id: com.bdayadev.oidc.example flutter: uses-material-design: true diff --git a/packages/oidc/example/test/conformance_flow_timeout_test.dart b/packages/oidc/example/test/conformance_flow_timeout_test.dart new file mode 100644 index 00000000..f9b7cafc --- /dev/null +++ b/packages/oidc/example/test/conformance_flow_timeout_test.dart @@ -0,0 +1,199 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; + +import '../integration_test/conformance/manager.dart'; + +// The conformance harness runs unattended: no user can dismiss a browser or +// complete a login. `flowTimeoutSeconds` is what makes a redirect that never +// arrives fail fast instead of waiting forever, and its dartdoc says so +// outright -- "otherwise loginAuthorizationCodeFlow() and logout hang +// indefinitely and leak the bound loopback socket". +// +// It was set for android/ios/macos and left null for linux/windows. Those are +// exactly the platforms where the Config RP plan hung until GitHub Actions +// killed the job ten minutes later, taking a pile of orphan chrome processes +// with it. The three platforms that had a timeout passed. +// +// A hang is a bad failure mode to test with: it produces no assertion, no +// message, and no stack -- just a dead job and a bill for the runner minutes. +// So assert the configuration instead, where it is cheap and immediate. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final manager = conformanceManager( + 'https://www.certification.openid.net/test/abc', + clientId: 'my_client', + clientSecret: 'my_client_secret', + redirectUri: Uri.parse('http://localhost:22434'), + ); + final options = manager.settings.options!; + + test('every platform the conformance suite runs on has a flow timeout', () { + expect( + options.linux.flowTimeoutSeconds, + isNotNull, + reason: + 'linux drives the loopback listener; without a timeout a redirect ' + 'that never arrives hangs the whole job', + ); + expect( + options.windows.flowTimeoutSeconds, + isNotNull, + reason: 'windows drives the same loopback listener as linux', + ); + expect( + options.web.flowTimeoutSeconds, + isNotNull, + reason: + 'web hung identically; hiddenIframeTimeout does not cover the popup ' + 'the interactive flow uses', + ); + // Regression: these three were already set, and are the reason + // macos/ios/android pass while the others hang. + expect(options.macos.flowTimeoutSeconds, isNotNull); + expect(options.ios.flowTimeoutSeconds, isNotNull); + expect(options.android.flowTimeoutSeconds, isNotNull); + }); + + // #469: on the iOS CI simulator, SafariViewService has been measured taking + // ~49.5s to start loading the authorize URL. A timeout shorter than that + // cancels a browser that is merely slow, and the module is left at + // status=WAITING while the library did nothing wrong. + test('the iOS timeout outlasts the slowest simulator browser start seen', () { + expect(options.ios.flowTimeoutSeconds, iosConformanceFlowTimeoutSeconds); + expect( + iosConformanceFlowTimeoutSeconds, + greaterThanOrEqualTo(observedIosBrowserStartSeconds * 1.5), + reason: + 'leave a margin over the worst observed start; at 30s the ' + 'Hybrid RP plan failed intermittently on ios only', + ); + // The ios job's budget (timeout-minutes: 60) still has to absorb a run in + // which a few modules really do hang. + expect(iosConformanceFlowTimeoutSeconds, lessThanOrEqualTo(120)); + }); + + // A plan's FIRST desktop login pays for the CI runner's browser + // cold-starting. One took 30.07s to reach the suite (main run 37502027906, + // linux implicit) against a 30s timeout, and the module was left WAITING + // while the library did nothing wrong. Every later login took <= 7.9s. + test('the desktop timeout outlasts the slowest first login seen', () { + for (final (name, seconds) in [ + ('linux', options.linux.flowTimeoutSeconds), + ('windows', options.windows.flowTimeoutSeconds), + ]) { + expect(seconds, desktopConformanceFlowTimeoutSeconds, reason: name); + } + expect( + desktopConformanceFlowTimeoutSeconds, + greaterThanOrEqualTo(observedDesktopFirstLoginSeconds * 1.5), + reason: + 'leave a margin over the worst observed first login; at 30s the ' + 'Implicit RP plan failed on linux', + ); + }); + + test('the timeout is short enough to fail before the CI job is killed', () { + // The linux job was killed at ~10 min. A per-module timeout has to leave + // room for the remaining modules to still run, otherwise the first hang + // consumes the budget and the rest of the plan never reports. + for (final (name, seconds) in [ + ('linux', options.linux.flowTimeoutSeconds), + ('windows', options.windows.flowTimeoutSeconds), + ]) { + expect( + seconds, + allOf(greaterThan(0), lessThanOrEqualTo(60)), + reason: + '$name: a timeout longer than a minute per module cannot ' + 'complete a 6-module plan inside the job limit', + ); + } + }); + + // Web was left out of the budget check above, and web is where the budget is + // tightest: it runs the LARGEST plans against the SHORTEST per-test cap. + // + // * Hybrid RP is 48 modules and Implicit RP is 27 (shared_e2e.dart:313-322). + // * Patrol drives web through Playwright, whose per-test cap defaults to + // 10 minutes (patrol_plus/web_runner/playwright.config.ts:66 -- + // `timeout: timeout ?? 10 * 60 * 1000`). The web job pins the same value + // explicitly via `--web-timeout`. + // + // At 30s per module, one plan in which every module times out needs + // 48 x 30s = 1440s against a 600s cap. It cannot finish, so Playwright kills + // the test mid-plan -- and a killed test never emits a closing patrol entry, + // which is exactly the reporting hole that made the web job print + // "Total: 13, Successful: 11, Failed: 0" and exit 1 with no name attached + // (run 90178636000: Hybrid and Implicit each consumed ~629s and emitted + // nothing at all). + // + // The budget is what decides whether a broken plan REPORTS or VANISHES. Sized + // so the plan runs to completion and fails on its own + // `successfulLogins > 0` assertion -- which names the plan and dumps the + // per-module suite status -- instead of being killed with no output. + group('the web budget lets the largest plan finish inside Playwright cap', () { + // The cap the web job PASSES, not Playwright's 600s default + // (patrol_plus/web_runner/playwright.config.ts:66): the job runs + // `patrol test --web-timeout 900000`. Keep the two in step -- this + // constant is the only thing tying the arithmetic to that flag. + const playwrightPerTestCapSeconds = 900; + // shared_e2e.dart:313-322 lists the module counts; Hybrid is the largest. + const largestPlanModules = 48; + // Module-instance creation, the setup poll loop, the suite-status fetch and + // the per-module log fetch all run OUTSIDE the browser flow, and the + // harness pays them once PER MODULE -- not once per plan. Charging them + // once was the first version of this assertion, and it let 48 x 10 + 60 = + // 540 read as fitting while the real cost was 686s. + // + // 4.3s measured on the eight back-channel modules of + // oidcc-client-back-channel-logout-rp-basic (linux, commit 05b0c84): each + // took 34-35s wall against flowTimeoutSeconds: 30. Rounded UP, because + // web pays more than linux for the same work -- its suite traffic goes + // through a CORS proxy (shared_e2e.dart builds the Dio baseUrl through + // cors-proxy.bdaya-dev.workers.dev when kIsWeb). + const perModuleOverheadSeconds = 5; + // Plan creation and the final aggregate, paid once. + const perPlanOverheadSeconds = 20; + + test('a plan whose every module times out still fits', () { + final webTimeout = options.web.flowTimeoutSeconds; + expect(webTimeout, isNotNull); + final worstCase = + largestPlanModules * (webTimeout! + perModuleOverheadSeconds) + + perPlanOverheadSeconds; + expect( + worstCase, + lessThanOrEqualTo(playwrightPerTestCapSeconds), + reason: + 'web: $largestPlanModules modules x ' + '(${webTimeout}s flow + ${perModuleOverheadSeconds}s overhead) + ' + '${perPlanOverheadSeconds}s = ${worstCase}s exceeds the ' + '${playwrightPerTestCapSeconds}s Playwright per-test cap, so a ' + 'plan in which the redirects do not arrive is KILLED mid-plan ' + 'rather than failing. A killed test emits no closing patrol entry, ' + 'so it is counted in Total and in none of ' + 'successful/failed/skipped, and the job exits 1 naming nothing. ' + 'Lower flowTimeoutSeconds for web in ' + 'integration_test/conformance/manager.dart, or raise ' + '--web-timeout in the web job.', + ); + }); + + test('but is still generous relative to a healthy web flow', () { + // A web module that succeeds completes well inside 2s end to end: the + // Basic RP plan ran all 14 of its modules in 26s of wall clock in the + // same job. The bound must fail a hang fast without being so tight that + // a slow-but-working redirect is cut off. + expect( + options.web.flowTimeoutSeconds, + greaterThanOrEqualTo(10), + reason: + 'anything under 10s is within an order of magnitude of a healthy ' + 'flow and would start failing real redirects', + ); + }); + }); +} diff --git a/packages/oidc/example/test/conformance_log_since_test.dart b/packages/oidc/example/test/conformance_log_since_test.dart new file mode 100644 index 00000000..382e26f1 --- /dev/null +++ b/packages/oidc/example/test/conformance_log_since_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../integration_test/conformance/api.dart'; + +void main() { + group('takeUnseenLogEntries', () { + test('asks from one millisecond before the newest entry', () { + final seen = {}; + final r = takeUnseenLogEntries([ + {'_id': 'a', 'time': 1000}, + {'_id': 'b', 'time': 1005}, + ], seen); + expect(r.since, 1004); + expect(r.fresh.map((e) => e['_id']), ['a', 'b']); + }); + + test( + 'an entry written later in the same millisecond is still delivered once', + () { + final seen = {}; + var r = takeUnseenLogEntries([ + {'_id': 'discovery', 'time': 2000}, + ], seen); + // The suite returns `time > since`, so the next read (since=1999) + // re-includes `discovery` plus `Setup Done` from the same millisecond. + r = takeUnseenLogEntries( + [ + {'_id': 'discovery', 'time': 2000}, + {'_id': 'done', 'msg': 'Setup Done', 'time': 2000}, + ], + seen, + since: r.since, + ); + expect(r.fresh.map((e) => e['msg']), ['Setup Done']); + expect(r.since, 1999); + }, + ); + + test('never moves since backwards for an out-of-order batch', () { + final seen = {}; + final r = takeUnseenLogEntries( + [ + {'_id': 'old', 'time': 10}, + ], + seen, + since: 500, + ); + expect(r.since, 500); + }); + }); +} diff --git a/packages/oidc/example/test/conformance_module_driving_test.dart b/packages/oidc/example/test/conformance_module_driving_test.dart new file mode 100644 index 00000000..917f65b9 --- /dev/null +++ b/packages/oidc/example/test/conformance_module_driving_test.dart @@ -0,0 +1,300 @@ +@TestOn('vm') +library; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../integration_test/conformance/api.dart'; + +// #467 review follow-up: CI run 37252425838 confirmed the Config RP plan's +// remaining per-module failures against real suite output (see api.dart's +// moduleFinishesBeforeUserinfo / requiresSecondLoginForKeyRotation docs for +// the suite-source citations). These tests pin the pure classification logic +// the harness now uses to drive those two modules correctly, and the +// transient-poll-retry decision added alongside it -- none of which need a +// live suite, or even a real Dio call, to verify. +void main() { + group('moduleFinishesBeforeUserinfo', () { + test('the jwks-uri-keys module finishes before userinfo', () { + expect( + moduleFinishesBeforeUserinfo( + 'oidcc-client-test-discovery-jwks-uri-keys', + ), + isTrue, + ); + }); + + test('other Config RP modules are unaffected', () { + for (final module in [ + 'oidcc-client-test-discovery-openid-config', + 'oidcc-client-test-discovery-issuer-mismatch', + 'oidcc-client-test-idtoken-sig-none', + 'oidcc-client-test-signing-key-rotation', + 'oidcc-client-test-signing-key-rotation-just-before-signing', + ]) { + expect(moduleFinishesBeforeUserinfo(module), isFalse, reason: module); + } + }); + }); + + group('requiresSecondLoginForKeyRotation', () { + test('the signing-key-rotation module needs a second login', () { + expect( + requiresSecondLoginForKeyRotation( + 'oidcc-client-test-signing-key-rotation', + ), + isTrue, + ); + }); + + test('the just-before-signing variant is a DIFFERENT module and is not ' + 'matched by a substring/prefix check', () { + // This module already passes with a single login (confirmed on CI run + // 37252425838: status=FINISHED result=PASSED after one login) because + // it rotates the key BEFORE signing the first id_token rather than + // requiring a second interaction. A prefix or substring match on + // 'oidcc-client-test-signing-key-rotation' would wrongly rope this + // module into issuing (and waiting on) a second login it does not + // need. + expect( + requiresSecondLoginForKeyRotation( + 'oidcc-client-test-signing-key-rotation-just-before-signing', + ), + isFalse, + ); + }); + + test('other Config RP modules are unaffected', () { + for (final module in [ + 'oidcc-client-test-discovery-openid-config', + 'oidcc-client-test-discovery-jwks-uri-keys', + 'oidcc-client-test-discovery-issuer-mismatch', + 'oidcc-client-test-idtoken-sig-none', + ]) { + expect( + requiresSecondLoginForKeyRotation(module), + isFalse, + reason: module, + ); + } + }); + }); + + group('isTransientConformancePollError', () { + final path = RequestOptions(path: 'api/info/abc'); + + DioException timeoutError(DioExceptionType type) => + DioException(requestOptions: path, type: type); + + DioException badResponse(int statusCode) => DioException( + requestOptions: path, + type: DioExceptionType.badResponse, + response: Response(requestOptions: path, statusCode: statusCode), + ); + + for (final type in [ + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + DioExceptionType.connectionError, + ]) { + test('$type is transient', () { + expect(isTransientConformancePollError(timeoutError(type)), isTrue); + }); + } + + for (final statusCode in [500, 502, 503]) { + test('a $statusCode response is transient', () { + expect( + isTransientConformancePollError(badResponse(statusCode)), + isTrue, + ); + }); + } + + for (final statusCode in [400, 401, 404, 422]) { + test( + 'a $statusCode response is NOT transient (retrying cannot fix it)', + () { + expect( + isTransientConformancePollError(badResponse(statusCode)), + isFalse, + ); + }, + ); + } + + for (final type in [ + DioExceptionType.cancel, + DioExceptionType.badCertificate, + DioExceptionType.unknown, + ]) { + test('$type is not transient', () { + expect(isTransientConformancePollError(timeoutError(type)), isFalse); + }); + } + + test('a non-DioException error is not transient', () { + expect(isTransientConformancePollError(StateError('boom')), isFalse); + }); + }); + + group('retryTransientConformancePollErrors', () { + const fastDelay = Duration(milliseconds: 1); + final requestOptions = RequestOptions(path: 'api/info/abc'); + DioException transient() => DioException( + requestOptions: requestOptions, + type: DioExceptionType.connectionError, + ); + DioException nonTransient() => DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response(requestOptions: requestOptions, statusCode: 404), + ); + + test('succeeds on the first attempt without retrying', () async { + var calls = 0; + final result = await retryTransientConformancePollErrors(() async { + calls++; + return 'ok'; + }, initialDelay: fastDelay); + expect(result, 'ok'); + expect(calls, 1); + }); + + test( + 'retries a transient failure and returns the eventual success', + () async { + var calls = 0; + final result = await retryTransientConformancePollErrors( + () async { + calls++; + if (calls < 3) { + throw transient(); + } + return 'ok after retries'; + }, + maxAttempts: 5, + initialDelay: fastDelay, + ); + expect(result, 'ok after retries'); + expect(calls, 3); + }, + ); + + test( + 'does not retry a non-transient error: fails on the first attempt', + () async { + var calls = 0; + await expectLater( + retryTransientConformancePollErrors( + () async { + calls++; + throw nonTransient(); + }, + maxAttempts: 5, + initialDelay: fastDelay, + ), + throwsA(isA()), + ); + expect( + calls, + 1, + reason: 'a 404 cannot be fixed by waiting and retrying', + ); + }, + ); + + test('gives up after maxAttempts of a persistent transient error, without ' + 'retrying forever', () async { + var calls = 0; + await expectLater( + retryTransientConformancePollErrors(() async { + calls++; + throw transient(); + }, initialDelay: fastDelay), + throwsA(isA()), + ); + expect(calls, 3, reason: 'the default maxAttempts is 3'); + }); + }); + + // #469 web-job follow-up: the session-management plan runs for real on web + // (it is markTestSkipped everywhere else -- see shared_e2e.dart's + // supportsSessionManagement), and the module stayed at status=WAITING + // because OidcSessionManagementSettings.enabled defaults to false and the + // harness called logout() immediately after login, with no + // check_session_iframe traffic at all. These tests pin the pure logic the + // fix added: which module needs the setting turned on, and which suite log + // message actually confirms a postMessage round trip (as opposed to merely + // loading the iframe). + group('requiresSessionManagementMonitoring', () { + test('the session-management module needs it', () { + expect( + requiresSessionManagementMonitoring( + 'oidcc-client-test-session-management', + ), + isTrue, + ); + }); + + test('other Config/logout modules do not', () { + for (final module in [ + 'oidcc-client-test-discovery-openid-config', + 'oidcc-client-test-discovery-jwks-uri-keys', + 'oidcc-client-test-signing-key-rotation', + 'oidcc-client-test-rp-init-logout', + ]) { + expect( + requiresSessionManagementMonitoring(module), + isFalse, + reason: module, + ); + } + }); + }); + + group('isSessionCheckPostMessageLogEntry', () { + test('matches the logged-in variant', () { + expect( + isSessionCheckPostMessageLogEntry( + 'OP iframe received postMessage request from RP iframe', + ), + isTrue, + ); + }); + + test( + 'matches the not-logged-in variant (same boolean flips either way)', + () { + expect( + isSessionCheckPostMessageLogEntry( + 'OP iframe received postMessage request from RP iframe but the ' + 'user is not logged in', + ), + isTrue, + ); + }, + ); + + test( + 'does NOT match merely loading the iframe -- that is a weaker signal', + () { + expect( + isSessionCheckPostMessageLogEntry( + 'The client requested check_session_iframe', + ), + isFalse, + ); + }, + ); + + test('does not match an unrelated log message', () { + expect(isSessionCheckPostMessageLogEntry('Setup Done'), isFalse); + }); + + test('does not match null', () { + expect(isSessionCheckPostMessageLogEntry(null), isFalse); + }); + }); +} diff --git a/packages/oidc/example/test/conformance_module_verdict_test.dart b/packages/oidc/example/test/conformance_module_verdict_test.dart new file mode 100644 index 00000000..a8e3d72e --- /dev/null +++ b/packages/oidc/example/test/conformance_module_verdict_test.dart @@ -0,0 +1,311 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; + +import '../integration_test/conformance/api.dart'; + +// #467: the harness used to gate an entire RP plan on +// `expect(successfulLogins, greaterThan(0))`, which only proves SOME module +// logged in. A negative module such as oidcc-client-test-missing-athash that +// WRONGLY logs in stays invisible to that aggregate as long as any other +// module in the plan still passes -- which is exactly how the implicit-flow +// at_hash gap shipped green in #447's CI. +// +// The fix asks the suite itself for each module's own verdict +// (`GET api/info/{id}`, TestInfoResponse.status/.result -- confirmed against +// real CI output and the suite's own OpenAPI document, see api.dart). These +// tests cover the pure classification of that verdict: which `status` values +// are terminal and which `result` values this harness accepts. Both are +// plain enums copied from TestModule.Status/TestModule.Result in the suite's +// Java source, so there is no live network call to fake here -- the thing +// worth pinning down offline is "does the mapping match the enum", not +// Dio's plumbing (already exercised, unmocked, by every CI run of +// runOidcConformanceTest). +void main() { + group('isAcceptableConformanceResult', () { + for (final result in ['PASSED', 'WARNING', 'REVIEW', 'SKIPPED']) { + test('accepts $result', () { + expect(isAcceptableConformanceResult(result), isTrue); + }); + } + + for (final result in ['FAILED', 'UNKNOWN']) { + test('rejects $result', () { + expect(isAcceptableConformanceResult(result), isFalse); + }); + } + + test('rejects null (never a verdict)', () { + expect(isAcceptableConformanceResult(null), isFalse); + }); + + test( + 'rejects an unrecognised string rather than accepting it by default', + () { + // TestModule.Result is a closed Java enum; a value this harness has + // never seen is far more likely to be a suite upgrade this code has not + // been taught about yet than a new kind of success. Fail loud, not + // open. + expect(isAcceptableConformanceResult('SOME_FUTURE_RESULT'), isFalse); + }, + ); + }); + + group('isTerminalConformanceStatus', () { + for (final status in ['FINISHED', 'INTERRUPTED']) { + test('$status is terminal', () { + expect(isTerminalConformanceStatus(status), isTrue); + }); + } + + for (final status in [ + 'NOT_YET_CREATED', + 'CREATED', + 'CONFIGURED', + 'RUNNING', + 'WAITING', + ]) { + test('$status is not terminal', () { + expect(isTerminalConformanceStatus(status), isFalse); + }); + } + + test('null is not terminal', () { + expect(isTerminalConformanceStatus(null), isFalse); + }); + }); + + group('acceptableConformanceResults / terminalConformanceStatuses', () { + // Guards the two sets against silently drifting apart from the functions + // above (e.g. someone adds a value to the set but not the predicate, or + // vice versa) since the harness's own failure-message text quotes the set + // directly. + test('every accepted result is reachable through the predicate', () { + for (final result in acceptableConformanceResults) { + expect(isAcceptableConformanceResult(result), isTrue, reason: result); + } + }); + + test('every terminal status is reachable through the predicate', () { + for (final status in terminalConformanceStatuses) { + expect(isTerminalConformanceStatus(status), isTrue, reason: status); + } + }); + + test("FAILED and INTERRUPTED (the suite's certification blockers) are not " + 'both accepted-as-result and terminal-as-status confused with each ' + 'other', () { + // FAILED is a RESULT value and must never be treated as a STATUS by + // isTerminalConformanceStatus, nor should INTERRUPTED -- a STATUS -- + // ever be treated as an acceptable RESULT. The two enums share no + // values today, but a typo'd lookup (passing a result where a status is + // expected, or back) would otherwise fail silently rather than loudly. + expect(isTerminalConformanceStatus('FAILED'), isFalse); + expect(isAcceptableConformanceResult('INTERRUPTED'), isFalse); + }); + }); + + // A module is run again on a fresh instance only when the suite itself + // proves it never saw the browser: anything else could replace a real + // verdict with a luckier one. + group('shouldRerunModuleOnFreshInstance', () { + final noAuthorize = >[ + {'msg': 'Setup Done', 'time': 1}, + {'msg': 'Discovery endpoint', 'startBlock': true, 'time': 2}, + ]; + final withAuthorize = >[ + ...noAuthorize, + {'msg': 'Authorization endpoint', 'startBlock': true, 'time': 3}, + ]; + // What a REFUSED authorization request leaves: the dispatcher's arrival + // line, then a failure, and no "Authorization endpoint" block (e.g. an + // instance already FINISHED throwing "Illegal test state change", or + // OIDCCClientTestDiscoveryIssuerMismatch throwing before the block). + final refusedAuthorize = >[ + ...noAuthorize, + {'msg': 'Incoming HTTP request to /test/abc/authorize', 'time': 3}, + { + 'msg': 'Illegal test state change: FINISHED -> RUNNING', + 'result': 'FAILURE', + 'time': 4, + }, + ]; + + bool rerun({ + bool loggedIn = false, + int attempt = 1, + String? status = 'WAITING', + List>? log, + }) => shouldRerunModuleOnFreshInstance( + loggedIn: loggedIn, + attempt: attempt, + suiteStatus: status, + suiteLog: log ?? noAuthorize, + ); + + test('reruns once when the instance is still WAITING and no ' + 'authorization request ever arrived', () { + expect(rerun(), isTrue); + }); + + test('never reruns once an authorization request was handled ' + '(every negative module ends like this)', () { + expect(rerun(log: withAuthorize), isFalse); + }); + + test('a request the suite REFUSED still arrived, so no rerun', () { + expect(suiteLogShowsAuthorizationRequest(refusedAuthorize), isTrue); + expect(rerun(log: refusedAuthorize), isFalse); + }); + + test('the arrival line counts with a query string too', () { + expect( + suiteLogShowsAuthorizationRequest([ + { + 'msg': 'Incoming HTTP request to /test/abc/authorize?state=x', + 'time': 1, + }, + ]), + isTrue, + ); + }); + + test('other endpoints arriving do not count', () { + expect( + suiteLogShowsAuthorizationRequest([ + {'msg': 'Incoming HTTP request to /test/abc/jwks', 'time': 1}, + { + 'msg': [ + 'Incoming HTTP request to /test/abc', + '.well-known/openid-configuration', + ].join('/'), + 'time': 2, + }, + ]), + isFalse, + ); + }); + + for (final (status, result) in [ + ('FINISHED', 'FAILED'), + ('FINISHED', 'PASSED'), + ('INTERRUPTED', 'FAILED'), + ]) { + test('never reruns an instance that has a verdict ' + '($status/$result), whatever its log shows', () { + expect(rerun(status: status), isFalse); + }); + } + + test('an unknown status (summary unreadable) proves nothing', () { + expect(rerun(status: null), isFalse); + }); + + test('never reruns a rerun', () { + expect(maxModuleReruns, 1); + expect(rerun(attempt: 2), isFalse); + }); + + test('never reruns a login that succeeded', () { + expect(rerun(loggedIn: true), isFalse); + }); + + test('an unreadable (empty) log proves nothing, so no rerun', () { + expect(rerun(log: const []), isFalse); + }); + + test('a log line that merely mentions authorization is not a request ' + 'block', () { + expect( + suiteLogShowsAuthorizationRequest([ + {'msg': 'Authorization endpoint response params', 'time': 1}, + ]), + isFalse, + ); + }); + }); + + // #469: on iOS the per-module failure line is the only harness output in + // the job log, so it has to say by itself whether the suite ever received + // the authorization request -- the question a stuck `status=WAITING` + // negative module could not answer. + group('describeSuiteLogForFailure', () { + Map block(String msg, int time) => { + 'msg': msg, + 'startBlock': true, + 'time': time, + }; + + test('says so when the suite never received an authorization ' + 'request', () { + final digest = describeSuiteLogForFailure([ + {'msg': 'Setup Done', 'time': 1000}, + block('Discovery endpoint', 2000), + block('Jwks endpoint', 2500), + ]); + expect(digest, contains('NO authorization request received')); + expect(digest, contains('Discovery endpoint')); + expect(digest, contains('Jwks endpoint')); + }); + + test('times every request block from the first authorization ' + 'request', () { + final digest = describeSuiteLogForFailure([ + block('Discovery endpoint', 9000), + block('Authorization endpoint', 10000), + {'msg': 'Created authorization code', 'time': 10100}, + block('Jwks endpoint', 15020), + ]); + expect(digest, isNot(contains('NO authorization request'))); + expect(digest, contains('Discovery endpoint@-1.00s')); + expect(digest, contains('Authorization endpoint@+0.00s')); + expect(digest, contains('Jwks endpoint@+5.02s')); + }); + + test('ends with the last entries, error included', () { + final digest = describeSuiteLogForFailure([ + block('Authorization endpoint', 0), + {'msg': 'one', 'time': 1}, + {'msg': 'two', 'time': 2}, + { + 'msg': 'Got unexpected HTTP call', + 'result': 'FAILURE', + 'error': 'boom', + 'time': 3, + }, + ], tailLength: 2); + expect(digest, contains('last 2:')); + expect(digest, isNot(contains('one'))); + expect(digest, contains('two')); + expect(digest, contains('[FAILURE]@+0.00s Got unexpected HTTP call')); + expect(digest, contains('error: boom')); + }); + + test('places the first authorization request against the client login ' + 'start when that is known', () { + final entries = [ + block('Discovery endpoint', 100000), + block('Authorization endpoint', 150000), + ]; + expect( + describeSuiteLogForFailure(entries, clientLoginStartedAtMs: 100250), + contains( + 'first authorization request arrived 49.75s after the client ' + 'started the login', + ), + ); + expect( + describeSuiteLogForFailure(entries), + isNot(contains('after the client started the login')), + ); + }); + + test('an empty log is reported, not hidden', () { + expect( + describeSuiteLogForFailure(const []), + 'suite log: empty or unreadable', + ); + }); + }); +} diff --git a/packages/oidc/example/test/conformance_plan_variant_test.dart b/packages/oidc/example/test/conformance_plan_variant_test.dart new file mode 100644 index 00000000..f6e8cceb --- /dev/null +++ b/packages/oidc/example/test/conformance_plan_variant_test.dart @@ -0,0 +1,751 @@ +@TestOn('vm') +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc/oidc.dart'; + +import '../integration_test/conformance/api.dart'; +import '../integration_test/shared_e2e.dart'; + +// The Config RP plan shipped broken: the harness sent request_type and +// client_registration, and the conformance suite answered +// +// TestModule 'oidcc-client-test-discovery-openid-config' requires a value +// for variant 'client_auth_type' +// +// as an HTTP 400 at plan creation. Nothing local objected, because +// `prepareTestPlanRequest` will happily build a request for any plan out of +// whatever dimensions it is handed. The Basic plan resolves client_auth_type +// on its own, so the omission was invisible on the only plan that had ever +// run, and the mistake could only be discovered by a CI round trip against a +// live third-party server. +// +// That round trip is the thing worth removing. A plan whose modules require a +// dimension must fail HERE, in an offline unit test, naming the dimension -- +// not thirty minutes later as a 400 from certification.openid.net. +void main() { + const configPlan = 'oidcc-client-config-certification-test-plan'; + const basicPlan = 'oidcc-client-basic-certification-test-plan'; + + (String, Map) build( + String planName, { + Map? extraVariant, + String? requestType = 'plain_http_request', + }) => prepareTestPlanRequest( + planName: planName, + description: 'test', + clientId: 'my_client', + redirectUri: 'http://localhost:22434', + requestType: requestType, + clientRegistration: 'static_client', + extraVariant: extraVariant, + ); + + test('the config plan is rejected locally when client_auth_type is absent', () { + expect( + () => build(configPlan), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('client_auth_type'), contains(configPlan)), + ), + ), + reason: + 'the suite rejects this plan at creation without client_auth_type, ' + 'so building the request must fail here rather than over the network', + ); + }); + + test('the hybrid plan is rejected locally when client_auth_type is sent', () { + // The mirror image of the config case, and the reason this cannot be + // reasoned out offline: the suite sets client_auth_type itself for these + // plans and rejects any attempt to also set it. + // + // Variant 'client_auth_type' has been set by user, but test plan + // already sets this variant for module 'oidcc-client-test' + // + // Basic defaults it, Config demands it, Hybrid/Implicit forbid it. Same + // dimension, three rules, one HTTP 400 apiece to find out. + expect( + () => build( + 'oidcc-client-hybrid-certification-test-plan', + extraVariant: {'client_auth_type': 'client_secret_basic'}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('client_auth_type'), + ), + ), + ); + }); + + test('the hybrid plan builds when client_auth_type is left alone', () { + final (path, _) = build('oidcc-client-hybrid-certification-test-plan'); + expect(path, isNot(contains('client_auth_type'))); + }); + + test('the dynamic plan is rejected locally without client_auth_type', () { + // Dynamic RP is the fourth distinct combination for the same three + // dimensions: it FORBIDS request_type and client_registration, and + // REQUIRES client_auth_type. Both forbidden rules were recorded from + // earlier 400s; the required one was not, so the plan still failed at + // creation: + // TestModule 'oidcc-client-test-discovery-webfinger-acct' requires a + // value for variant 'client_auth_type' + expect( + () => build( + 'oidcc-client-dynamic-certification-test-plan', + requestType: null, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('client_auth_type'), + ), + ), + ); + }); + + test('the dynamic plan is rejected locally when request_type is sent', () { + // A SECOND dimension with per-plan rules, found the same way as the first: + // Variant 'request_type' has been set by user, but test plan already + // sets this variant + // Every other plan requires plain_http_request, so "always send it" looked + // like a constant until this plan proved it was an assumption. + // client_auth_type is supplied so the REQUIRED check passes and this + // isolates the FORBIDDEN one; without it the required check fires first + // and the assertion would pass on the wrong message. + expect( + () => build( + 'oidcc-client-dynamic-certification-test-plan', + extraVariant: {'client_auth_type': 'client_secret_basic'}, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('request_type'), + ), + ), + ); + }); + + test('the config plan builds once client_auth_type is supplied', () { + final (path, _) = build( + configPlan, + extraVariant: {'client_auth_type': 'client_secret_basic'}, + ); + expect(path, contains('client_auth_type')); + expect(path, contains('client_secret_basic')); + }); + + test( + 'the basic plan still builds without it, because the suite defaults it', + () { + // Guards the fix against overcorrection: requiring the dimension + // everywhere would break the one plan that is certified and green. + final (path, _) = build(basicPlan); + expect(path, isNot(contains('client_auth_type'))); + }, + ); + + group('logout profiles are recognised so the harness logs out', () { + // Four plans failed because the harness only ever logged in. The modules + // wait for an end-session request, so each timed out at + // flowTimeoutSeconds and reported no user. + for (final plan in [ + 'oidcc-client-rp-initiated-logout-rp-basic', + 'oidcc-client-front-channel-logout-rp-basic', + 'oidcc-client-back-channel-logout-rp-basic', + 'oidcc-client-rp-session-management-rp-basic', + // Not wired yet; must still be recognised the day they are. + 'oidcc-client-rp-initiated-logout-rp-hybrid', + 'oidcc-client-back-channel-logout-rp-implicit', + ]) { + test('$plan drives a logout', () { + expect(isLogoutConformancePlan(plan), isTrue); + }); + } + + for (final plan in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-hybrid-certification-test-plan', + 'oidcc-client-config-certification-test-plan', + 'oidcc-client-formpost-basic-certification-test-plan', + ]) { + test('$plan does not', () { + expect(isLogoutConformancePlan(plan), isFalse); + }); + } + }); + + group('form_post needs a transport that can read a POST body', () { + test('custom scheme cannot receive a form POST', () { + expect( + canReceiveFormPost( + Uri.parse('com.bdayadev.oidc.example:/oauth2redirect'), + 'macos', + ), + isFalse, + reason: 'a custom scheme is not an HTTP endpoint; no browser can POST', + ); + }); + + test('the desktop loopback listener can, as of 1.1.0', () { + // An earlier version of this test asserted the loopback listener could + // NOT (oidc_loopback_listener answered 405 to every non-GET), and for a + // while that assertion was honest. oidc_loopback_listener 1.1.0 reads an + // application/x-www-form-urlencoded POST body and folds it into the + // returned Uri's query parameters, so the capability is real now -- + // OAuth 2.0 Form Post Response Mode is deliverable to linux and windows. + expect( + canReceiveFormPost(Uri.parse('http://localhost:22434'), 'linux'), + isTrue, + ); + expect( + canReceiveFormPost(Uri.parse('http://localhost:22434'), 'windows'), + isTrue, + ); + }); + + test('the web page still cannot: a POST body is unreadable from JS', () { + // redirect.html reads location.hash/search; the platform gives a page + // script no access to the request body that delivered it. The URI alone + // cannot separate this case from desktop loopback -- both are http(s) -- + // which is why the predicate takes the platform too. + expect( + canReceiveFormPost( + Uri.parse('https://rp.oidc.test:22433/redirect.html'), + 'Web', + ), + isFalse, + ); + }); + + test('the formpost plans are recognised, and others are not', () { + for (final p in [ + 'oidcc-client-formpost-basic-certification-test-plan', + 'oidcc-client-formpost-hybrid-certification-test-plan', + 'oidcc-client-formpost-implicit-certification-test-plan', + ]) { + expect(isFormPostConformancePlan(p), isTrue); + } + expect( + isFormPostConformancePlan('oidcc-client-basic-certification-test-plan'), + isFalse, + ); + }); + }); + + group('the registered client declares its application_type', () { + // Registration 1.0 section 2: application_type "The default, if omitted, + // is web". A desktop RP registering http://localhost with hybrid/implicit + // response types is therefore judged as a WEB client and refused: + // + // [FAILURE] redirect_uri is one of the registered uris but uses http + // scheme which is not allowed when application_type is web and response + // type is not code + // + // 36 of those on the first live linux run (PR #447), one per fragment-mode + // module. The same section sanctions our exact shape for native clients: + // "Native Clients MUST only register redirect_uris using custom URI + // schemes or loopback URLs using the http scheme; loopback URLs use + // localhost or the IP loopback literals". The fix is telling the truth: + // every non-web platform here IS a native client. + test('the plan request carries application_type when given', () { + final (_, body) = prepareTestPlanRequest( + planName: basicPlan, + description: 'test', + clientId: 'my_client', + redirectUri: 'http://localhost:22434', + requestType: 'plain_http_request', + clientRegistration: 'static_client', + applicationType: 'native', + ); + expect((body['client']! as Map)['application_type'], 'native'); + }); + + test('omitted means absent, not defaulted here', () { + // The suite owns the default; the harness must not invent one silently. + final (_, body) = build(basicPlan); + expect((body['client']! as Map).containsKey('application_type'), isFalse); + }); + + test('every non-web platform is native; web is web', () { + for (final p in ['linux', 'windows', 'macos', 'ios', 'android']) { + expect(applicationTypeForPlatform(p), 'native', reason: p); + } + expect(applicationTypeForPlatform('Web'), 'web'); + }); + }); + + // The fragment gate (canReceiveFragmentResponse) is gone, deliberately. + // oidc_loopback_listener 1.1.0 recovers a fragment via its JS relay page and + // oidc_desktop 1.1.0 enables it exactly when the flow's response mode needs + // it, so every platform can now receive a fragment response and a + // universally-true gate is dead code. The response-mode classification the + // gate leaned on (formpost overrides the hybrid/implicit fragment default) + // lives on, tested, in oidc_desktop's responseArrivesInFragment. + + group( + 'third-party-initiated login needs an endpoint the app cannot host', + () { + test('the 3rd-party-init plan is recognised', () { + expect( + isThirdPartyInitPlan( + 'oidcc-client-test-3rd-party-init-login-test-plan', + ), + isTrue, + ); + }); + + test('ordinary plans are not', () { + for (final p in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-dynamic-certification-test-plan', + 'oidcc-client-rp-initiated-logout-rp-basic', + ]) { + expect(isThirdPartyInitPlan(p), isFalse, reason: p); + } + }); + }, + ); + + group('module-level variants differ from plan-level ones', () { + test('no plan restates a dimension at the MODULE endpoint', () { + // This asserted the opposite until the suite's source explained the 500. + // The observation was real: + // api/plan -> "Variant 'client_registration' has been set by user, + // but test plan already sets this variant" + // api/runner -> "createTestModule failed: Missing value for required + // variant parameter: client_registration" + // Forbidden on one endpoint, demanded by the other. But restating it to + // satisfy api/runner only moved the failure: the value then joins the + // arrayFilter that attaches the module to its plan, the stored entry + // does not match, and the request 500s with "modifiedCount=0". + // + // Both complaints have one answer -- send NO variant, per + // [moduleVariantComesFromPlan] -- so this map is empty and the rule it + // encoded is recorded as a dead end rather than a fact. + expect( + moduleVariantFor('oidcc-client-dynamic-certification-test-plan'), + isEmpty, + ); + }); + + test('other plans send no extra module variant', () { + for (final p in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-config-certification-test-plan', + 'oidcc-client-hybrid-certification-test-plan', + ]) { + expect(moduleVariantFor(p), isEmpty, reason: p); + } + }); + }); + + // The WebFinger modules address the running test through an alias: + // `acct:.@`, parsed by the conformance suite's + // TestDispatcher with + // + // ^acct:([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)@.*$ + // + // A dot inside the alias would be read as the alias/test-name separator, and + // an alias the suite has no running test for answers 404. So the alias is not + // cosmetic: without one the harness cannot name the test it is driving, and + // no WebFinger module can resolve. + group('conformance alias', () { + const dynamicPlan = 'oidcc-client-dynamic-certification-test-plan'; + + test('is legal for the suite dispatcher regex', () { + final alias = conformanceAlias(planName: dynamicPlan, platform: 'linux'); + expect(alias, matches(RegExp(r'^[a-zA-Z0-9_-]+$'))); + expect(alias, isNot(contains('.'))); + expect(alias, isNotEmpty); + }); + + test('is distinct per platform so matrix jobs cannot collide', () { + final seen = { + for (final p in ['linux', 'web', 'android', 'ios', 'macos', 'windows']) + p: conformanceAlias(planName: dynamicPlan, platform: p), + }; + expect(seen.values.toSet(), hasLength(seen.length)); + }); + + test('is distinct per plan', () { + expect( + conformanceAlias(planName: dynamicPlan, platform: 'linux'), + isNot( + conformanceAlias( + planName: 'oidcc-client-basic-certification-test-plan', + platform: 'linux', + ), + ), + ); + }); + + test('sanitises characters the dispatcher regex would reject', () { + final alias = conformanceAlias( + planName: 'weird.plan/name with spaces', + platform: 'linux', + ); + expect(alias, matches(RegExp(r'^[a-zA-Z0-9_-]+$'))); + }); + + test('is required only by the plan whose modules use WebFinger', () { + expect(planNeedsAlias(dynamicPlan), isTrue); + for (final p in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-config-certification-test-plan', + 'oidcc-client-hybrid-certification-test-plan', + 'oidcc-client-test-3rd-party-init-login-test-plan', + ]) { + expect(planNeedsAlias(p), isFalse, reason: p); + } + }); + + test('feeds a WebFinger identifier the library can normalize', () { + // The whole point of the alias: it is the only handle the RP has on the + // running test, and it reaches the suite through the identifier. + final identifier = webFingerIdentifierFor( + moduleName: 'oidcc-client-test-discovery-webfinger-acct', + alias: conformanceAlias(planName: dynamicPlan, platform: 'linux'), + host: 'www.certification.openid.net', + ); + final normalized = OidcUtils.normalizeWebFingerIdentifier(identifier!); + expect(normalized.host, 'www.certification.openid.net'); + expect(normalized.resource, startsWith('acct:')); + }); + + test('reaches the plan request body only when supplied', () { + final (_, withAlias) = prepareTestPlanRequest( + planName: dynamicPlan, + description: 'd', + clientId: 'c', + redirectUri: 'http://localhost:22434', + alias: 'oidc_linux', + extraVariant: const {'client_auth_type': 'client_secret_basic'}, + ); + expect(withAlias['alias'], 'oidc_linux'); + + final (_, withoutAlias) = prepareTestPlanRequest( + planName: dynamicPlan, + description: 'd', + clientId: 'c', + redirectUri: 'http://localhost:22434', + extraVariant: const {'client_auth_type': 'client_secret_basic'}, + ); + expect(withoutAlias.containsKey('alias'), isFalse); + }); + }); + + // The logout/session-management modules run GenerateSessionState on the + // AUTHORIZATION request, and it computes an origin from redirect_uri: + // + // uri.getScheme().toLowerCase() + "://" + uri.getHost().toLowerCase(...) + // -- GenerateSessionState.java:69, reached from + // AbstractOIDCCClientLogoutTest.validateAuthorizationEndpointRequestParameters + // + // A private-use scheme has no authority, so getHost() is null and the suite + // throws before issuing any redirect. Our redirect_uri is not the thing to + // change: RFC 8252 section 7.1 requires exactly this shape -- "as there is no + // naming authority for private-use URI scheme redirects, only a single slash + // ('/') appears after the scheme component" -- so adding an authority to + // satisfy the suite would break the BCP this package exists to conform to. + // When a module returns no user, the client can only say "no user". The + // suite knows WHY -- it wrote the reason into its own log -- and the harness + // already polls that log, but stops reading at "Setup Done" and throws the + // rest away. That is why 75 web fragment modules failed with nothing but a + // timeout: the provider's side of the story was fetched and discarded. + group('the suite log can be re-read after a module fails', () { + test('the log uri targets the instance and asks for the private view', () { + final uri = testLogUri(instanceId: 'abc123'); + expect(uri.path, 'api/log/abc123'); + // `public: false` is the authenticated view; the public one omits the + // entries that carry the failure reason. + expect(uri.queryParameters['public'], 'false'); + expect(uri.queryParameters.containsKey('since'), isFalse); + }); + + test('a since cursor is passed through when given', () { + final uri = testLogUri(instanceId: 'abc123', since: 1785233165171); + expect(uri.queryParameters['since'], '1785233165171'); + }); + }); + + // The Dynamic plan pins request_type=request_uri for ALL of its modules, and + // the suite RESOLVES that by fetching the URI: + // + // OIDCCClientDynamicTestPlan.java:41 + // new Variant(ClientRequestType.class, "request_uri") + // AbstractOIDCCClientTest.java:1019 + // callAndStopOnFailure(FetchRequestUriAndExtractRequestObject.class, "OIDCC-6.2") + // + // ClientRequestType has exactly three values -- plain_http_request, + // request_object, request_uri -- and none of them is PAR, so a PAR-issued + // `urn:ietf:params:oauth:request_uri:...` is not what this profile exercises. + // The RP must HOST a signed request object at an https URL the suite fetches, + // which a CI runner behind NAT cannot serve. Same physical constraint as + // back-channel logout, and not a gap in the library: request objects by VALUE + // are supported. + group('the Dynamic plan needs an RP-hosted request_uri', () { + test('the dynamic plan is recognised', () { + expect( + planNeedsHostedRequestUri( + 'oidcc-client-dynamic-certification-test-plan', + ), + isTrue, + ); + }); + + test('no other plan pins request_uri', () { + for (final p in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-config-certification-test-plan', + 'oidcc-client-hybrid-certification-test-plan', + 'oidcc-client-implicit-certification-test-plan', + 'oidcc-client-test-3rd-party-init-login-test-plan', + ]) { + expect(planNeedsHostedRequestUri(p), isFalse, reason: p); + } + }); + }); + + // Back-channel logout is the only profile with no browser in the loop: the OP + // POSTs the logout token straight to the RP. A CI runner on loopback is not + // reachable from the internet, so no backchannel_logout_uri value helps. + group('back-channel logout needs an OP-reachable RP', () { + test('the back-channel plan is recognised', () { + expect( + isBackChannelLogoutPlan('oidcc-client-back-channel-logout-rp-basic'), + isTrue, + ); + }); + + test('the browser-based logout plans are not', () { + for (final p in [ + 'oidcc-client-rp-initiated-logout-rp-basic', + 'oidcc-client-front-channel-logout-rp-basic', + 'oidcc-client-rp-session-management-rp-basic', + ]) { + expect(isBackChannelLogoutPlan(p), isFalse, reason: p); + } + }); + + test('it is still a logout plan, so the gates compose', () { + expect( + isLogoutConformancePlan('oidcc-client-back-channel-logout-rp-basic'), + isTrue, + ); + }); + }); + + group('session_state generation needs an authority in redirect_uri', () { + test('a loopback redirect can generate one', () { + expect( + canGenerateSessionState(Uri.parse('http://localhost:22434')), + isTrue, + ); + }); + + test('the web redirect can generate one', () { + expect( + canGenerateSessionState( + Uri.parse('http://localhost:22433/redirect.html'), + ), + isTrue, + ); + }); + + test('an RFC 8252 private-use scheme redirect cannot', () { + expect( + canGenerateSessionState( + Uri.parse('com.bdayadev.oidc.example:/oauth2redirect'), + ), + isFalse, + ); + }); + + test('the RFC 8252 example form cannot either', () { + // Straight from RFC 8252 section 7.1, so this pins the spec shape rather + // than our particular scheme. + expect( + canGenerateSessionState( + Uri.parse('com.example.app:/oauth2redirect/example-provider'), + ), + isFalse, + ); + }); + }); + + // OpenID Connect Session Management is kept web-only by design (oidc#467, + // user-approved after research): RFC 8252 section 8.12 forbids an embedded + // WebView for login on native platforms, and even granting one purely for + // session monitoring, OIDC Session Management 1.0 section 3.2 ties + // check_session_iframe's answer to the OP session cookie held by the + // EXTERNAL user-agent that §8.12 required login to run in -- a cookie no + // app-controlled frame on android/ios/macos/linux/windows has access to. + // Confirmed against the suite's own public log (run 37273454165): the RP's + // request sequence on linux/windows was discovery -> authorize -> token -> + // jwks -> userinfo -> end_session_endpoint, with ZERO requests to + // check_session_iframe at any point. + group('session management stays web-only', () { + test( + 'the basic/hybrid/implicit session-management plans are recognised', + () { + for (final plan in [ + 'oidcc-client-rp-session-management-rp-basic', + // Not wired yet; must still be recognised the day they are, same + // convention as isLogoutConformancePlan's own family. + 'oidcc-client-rp-session-management-rp-hybrid', + 'oidcc-client-rp-session-management-rp-implicit', + ]) { + expect( + isSessionManagementConformancePlan(plan), + isTrue, + reason: plan, + ); + } + }, + ); + + test('other logout-family plans are not session-management plans', () { + for (final plan in [ + 'oidcc-client-rp-initiated-logout-rp-basic', + 'oidcc-client-front-channel-logout-rp-basic', + 'oidcc-client-back-channel-logout-rp-basic', + 'oidcc-client-basic-certification-test-plan', + ]) { + expect(isSessionManagementConformancePlan(plan), isFalse, reason: plan); + } + }); + + test('supportsSessionManagement is exactly kIsWeb, which is false on this ' + 'VM test run', () { + // supportsSessionManagement is a direct alias for kIsWeb (by design: + // the capability IS "are we running as a web page", nothing more + // nuanced than that), so there is no independent logic to exercise + // beyond pinning that identity; a VM test can only observe the false + // branch, the true branch is exercised by this plan actually running + // on the web CI job. + expect(supportsSessionManagement, isFalse); + expect(supportsSessionManagement, kIsWeb); + }); + }); + + // Dynamic RP's 500 was never a missing dimension. The suite attaches a new + // module instance to its plan with a Mongo arrayFilter built from the variant + // the CALLER sent (DBTestPlanService.updateTestPlanWithModule): + // + // variant.getVariant().forEach((name, value) -> + // updateCriteria.and("module.variant." + name).is(value)); + // updateCriteria.and("module.testModule").is(testName); + // + // Every dimension sent becomes another equality the stored module entry must + // satisfy, so sending MORE over-constrains the filter and it matches nothing + // -- "modifiedCount=0". Sending none is the supported path: TestRunner only + // calls getFixedVariantIfOnlyOneMatchingModuleInPlan, which returns the + // module's OWN stored variant, when no variant arrived from the API. That + // value matches the filter by construction, whatever it happens to be. + group('module variant that the plan owns is not restated', () { + const dynamicPlan = 'oidcc-client-dynamic-certification-test-plan'; + + test('the dynamic plan lets the suite supply the module variant', () { + expect(moduleVariantComesFromPlan(dynamicPlan), isTrue); + }); + + test('every other plan still sends its own', () { + for (final p in [ + 'oidcc-client-basic-certification-test-plan', + 'oidcc-client-config-certification-test-plan', + 'oidcc-client-hybrid-certification-test-plan', + ]) { + expect(moduleVariantComesFromPlan(p), isFalse, reason: p); + } + }); + + test('omitting the variant leaves it out of the query entirely', () { + final uri = moduleInstanceUri(planId: 'p1', moduleName: 'm1'); + expect(uri.queryParameters.containsKey('variant'), isFalse); + expect(uri.queryParameters['plan'], 'p1'); + expect(uri.queryParameters['test'], 'm1'); + }); + + test('supplying one still encodes it', () { + final uri = moduleInstanceUri( + planId: 'p1', + moduleName: 'm1', + variant: const {'response_type': 'code'}, + ); + expect(uri.queryParameters['variant'], '{"response_type":"code"}'); + }); + }); + + // The two identifier shapes the suite's dispatcher parses. They are not + // interchangeable: each module validates the scheme of the resource it was + // asked with, so the acct form sent to the URL module is refused with + // "This test expects a webfinger request using URL syntax", and vice versa. + group('WebFinger identifiers', () { + const host = 'www.certification.openid.net'; + const alias = 'oidc_linux'; + + test('the acct module wants acct:.@', () { + expect( + webFingerIdentifierFor( + moduleName: 'oidcc-client-test-discovery-webfinger-acct', + alias: alias, + host: host, + ), + 'acct:$alias.oidcc-client-test-discovery-webfinger-acct@$host', + ); + }); + + test('the url module wants https:////', () { + expect( + webFingerIdentifierFor( + moduleName: 'oidcc-client-test-discovery-webfinger-url', + alias: alias, + host: host, + ), + 'https://$host/$alias/oidcc-client-test-discovery-webfinger-url', + ); + }); + + test('every other module resolves its issuer the ordinary way', () { + for (final m in [ + 'oidcc-client-test-discovery-openid-config', + 'oidcc-client-test-registration-dynamic', + 'oidcc-client-test', + ]) { + expect( + webFingerIdentifierFor(moduleName: m, alias: alias, host: host), + isNull, + reason: m, + ); + } + }); + + test('both identifiers normalize back to the suite host', () { + for (final m in [ + 'oidcc-client-test-discovery-webfinger-acct', + 'oidcc-client-test-discovery-webfinger-url', + ]) { + final identifier = webFingerIdentifierFor( + moduleName: m, + alias: alias, + host: host, + ); + expect( + OidcUtils.normalizeWebFingerIdentifier(identifier!).host, + host, + reason: m, + ); + } + }); + }); +} diff --git a/packages/oidc/example/test/patrol_tally_test.dart b/packages/oidc/example/test/patrol_tally_test.dart new file mode 100644 index 00000000..094a5ee7 --- /dev/null +++ b/packages/oidc/example/test/patrol_tally_test.dart @@ -0,0 +1,196 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; + +import '../tool/patrol_tally.dart'; + +// patrol_log_plus counts a test into `Total` the moment it announces `start` +// (patrol_log_reader.dart:69 -- `totalTests => _singleEntries.length`), and the +// three result buckets are independent filters over that same list: +// +// successfulTests -> status == success (:72) +// failedTests -> status == failure (:76) +// skippedTests -> status == skip (:83) +// +// A test whose closing entry never arrives keeps `openingTestEntry.status`, +// which is `TestEntryStatus.start` (patrol_single_test_entry.dart:33), and +// `start` is matched by NONE of the three. So a vanished test is counted in +// Total and nowhere else, and `failedTestsCount == 0` sends +// web_test_backend.dart:649 down the "Playwright process exited unexpectedly" +// branch instead of "Some tests failed." +// +// That is how the web job printed `Total: 13, Successful: 11, Failed: 0, +// Skipped: 0` and exited 1 without naming either lost test (run 90178636000). +// The invariant `successful + failed + skipped == total` is exactly the +// assertion patrol does not make, so make it here, over patrol's own output. + +/// The Hybrid/Implicit loss as it actually appeared in the web job, trimmed to +/// the lines the reconciler reads. Both markers are adjacent in the raw log -- +/// no `✅`, no `❌`, no exception banner between them. +const _webJobLoss = ''' +🧪 app_test OIDC Conformance: Basic RP +✅ OIDC Conformance: Basic RP (patrol_test/app_test.dart) (26s) +🧪 app_test OIDC Conformance: Hybrid RP +🧪 app_test OIDC Conformance: Implicit RP +🧪 app_test OIDC Conformance: RP-Initiated Logout +✅ OIDC Conformance: RP-Initiated Logout (patrol_test/app_test.dart) (42s) + +Test summary: +📝 Total: 13 +✅ Successful: 11 +❌ Failed: 0 +⏩ Skipped: 0 +📊 Report: /home/runner/work/oidc/oidc/packages/oidc/example/report.xml +⏱️ Duration: 25m 30s +'''; + +/// A healthy run: every started test reported, and the buckets add up. +const _healthyRun = ''' +🧪 app_test OIDC Conformance: Basic RP +✅ OIDC Conformance: Basic RP (patrol_test/app_test.dart) (26s) +🧪 app_test OIDC Conformance: Hybrid RP +❌ OIDC Conformance: Hybrid RP (patrol_test/app_test.dart) (31s) +⏩ app_test OIDC Conformance: Dynamic RP + +Test summary: +📝 Total: 3 +✅ Successful: 1 +❌ Failed: 1 + - OIDC Conformance: Hybrid RP +⏩ Skipped: 1 +📊 Report: /home/runner/work/oidc/oidc/packages/oidc/example/report.xml +⏱️ Duration: 2m 30s +'''; + +void main() { + group('reconcilePatrolTally', () { + test('accepts a run whose buckets add up to Total', () { + final result = reconcilePatrolTally(_healthyRun, minimumTests: 3); + expect(result.errors, isEmpty, reason: result.errors.join('\n')); + expect(result.total, 3); + expect(result.unreported, isEmpty); + }); + + test('rejects the web job run, and NAMES both tests that vanished', () { + final result = reconcilePatrolTally(_webJobLoss, minimumTests: 13); + + // The bug this exists to catch: 11 + 0 + 0 != 13. + expect(result.total, 13); + expect(result.successful, 11); + expect(result.failed, 0); + expect(result.skipped, 0); + expect(result.accountedFor, 11); + expect(result.unaccountedFor, 2); + + expect(result.errors, isNotEmpty); + final joined = result.errors.join('\n'); + // Naming the lost tests is the whole point: `Failed: 0` with exit 1 is + // unreadable, and re-running CI to find out which profile broke costs a + // 26-minute job. + expect(joined, contains('OIDC Conformance: Hybrid RP')); + expect(joined, contains('OIDC Conformance: Implicit RP')); + // A test that DID report must not be blamed. + expect(joined, isNot(contains('Basic RP'))); + expect(joined, isNot(contains('RP-Initiated Logout'))); + + expect( + result.unreported, + containsAll([ + 'OIDC Conformance: Hybrid RP', + 'OIDC Conformance: Implicit RP', + ]), + ); + }); + + test('fails the count even when the lifecycle markers are absent, so a ' + 'sharded or lifecycle-hidden run cannot pass silently', () { + // patrol hides the 🧪/✅ lines when `hideTestLifecycle` is on + // (web_test_backend.dart:608-626). The arithmetic must still hold; only + // the NAMING degrades. + const summaryOnly = ''' +Test summary: +📝 Total: 13 +✅ Successful: 11 +❌ Failed: 0 +⏩ Skipped: 0 +'''; + final result = reconcilePatrolTally(summaryOnly, minimumTests: 13); + expect(result.unaccountedFor, 2); + expect(result.errors, isNotEmpty); + expect(result.unreported, isEmpty); + expect( + result.errors.join('\n'), + contains('2'), + reason: 'the count must survive even when no name can be attached', + ); + }); + + test('rejects a run that printed no summary at all -- a job killed mid-run ' + 'must not read as success', () { + final result = reconcilePatrolTally( + '🧪 app_test OIDC Conformance: Basic RP\n', + minimumTests: 13, + ); + expect(result.errors, isNotEmpty); + expect(result.errors.join('\n'), contains('summary')); + }); + + test('enforces a minimum test count, so a run that executed nothing cannot ' + 'pass', () { + const ranOne = ''' +🧪 app_test OIDC Conformance: Basic RP +✅ OIDC Conformance: Basic RP (patrol_test/app_test.dart) (26s) + +Test summary: +📝 Total: 1 +✅ Successful: 1 +❌ Failed: 0 +⏩ Skipped: 0 +'''; + final result = reconcilePatrolTally(ranOne, minimumTests: 13); + expect(result.unaccountedFor, 0); + expect(result.errors, isNotEmpty); + expect( + result.errors.join('\n'), + contains('13'), + reason: 'the message must state the count that was expected', + ); + }); + + test('reads the last summary when a log contains more than one', () { + // patrol prints its summary once per invocation; a re-run inside the same + // step would append a second. The final one is the run being judged. + const twoSummaries = ''' +Test summary: +📝 Total: 1 +✅ Successful: 1 +❌ Failed: 0 +⏩ Skipped: 0 + +Test summary: +📝 Total: 13 +✅ Successful: 11 +❌ Failed: 0 +⏩ Skipped: 0 +'''; + final result = reconcilePatrolTally(twoSummaries, minimumTests: 13); + expect(result.total, 13); + expect(result.unaccountedFor, 2); + }); + }); + + group('formatFindings', () { + test('emits a GitHub Actions error annotation per finding', () { + final result = reconcilePatrolTally(_webJobLoss, minimumTests: 13); + final rendered = formatFindings(result); + expect(rendered, contains('::error::')); + expect(rendered, contains('OIDC Conformance: Hybrid RP')); + }); + + test('renders nothing for a healthy run', () { + final result = reconcilePatrolTally(_healthyRun, minimumTests: 3); + expect(formatFindings(result), isEmpty); + }); + }); +} diff --git a/packages/oidc/example/test/redirect_html_parity_test.dart b/packages/oidc/example/test/redirect_html_parity_test.dart new file mode 100644 index 00000000..b79a1c23 --- /dev/null +++ b/packages/oidc/example/test/redirect_html_parity_test.dart @@ -0,0 +1,52 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// The web conformance job serves THIS app's `web/redirect.html`, but the test +// that actually executes the page -- loading it in a browser and asserting it +// relays the URL fragment -- +// (`packages/oidc_web_core/test/redirect_html_fragment_test.dart`) runs inside +// `oidc_web_core` and can only reach that package's own copy. +// +// The copies are duplicated by hand: `redirect.html` is a template users are +// told to copy into their app ("If you upgrade the package you should RE-COPY +// this file", redirect.html:18), so the example apps hold copies too. Without +// this check the browser test proves a property of a file the web job never +// serves, and a fragment regression in the served copy would show up only as +// two conformance profiles silently timing out in CI -- the exact failure this +// whole change exists to make impossible. + +File _resolve(String relative) { + // `flutter test` runs with the package root as cwd. + final file = File(relative); + if (file.existsSync()) { + return file; + } + fail( + 'Could not find $relative from ${Directory.current.path}. ' + 'If the layout moved, update this test rather than deleting it: it is ' + 'what makes the browser-side fragment test apply to the file CI serves.', + ); +} + +void main() { + test('the served redirect.html is identical to the one the browser test ' + 'executes', () { + final served = _resolve('web/redirect.html'); + final tested = _resolve('../../oidc_web_core/example/web/redirect.html'); + + expect( + served.readAsStringSync(), + tested.readAsStringSync(), + reason: + 'packages/oidc/example/web/redirect.html (served by the web ' + 'conformance job) has drifted from ' + 'packages/oidc_web_core/example/web/redirect.html (the copy executed ' + 'by redirect_html_fragment_test.dart). Re-copy the template so the ' + 'fragment-relay guarantee still covers what CI runs.', + ); + }); +} diff --git a/packages/oidc/example/test/web_redirect_uri_test.dart b/packages/oidc/example/test/web_redirect_uri_test.dart new file mode 100644 index 00000000..7282c1c8 --- /dev/null +++ b/packages/oidc/example/test/web_redirect_uri_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:oidc_example/web_redirect_uri.dart'; + +// The conformance harness registers a redirect_uri with the test plan +// (shared_e2e.dart passes it to prepareTestPlanRequest), and the app under test +// builds its own at runtime. Those were two independent code paths: the app +// resolved `redirect.html` against `Uri.base`, while the harness returned a +// hardcoded `http://localhost:22433/redirect.html`. +// +// They agreed only because CI happened to serve the app at exactly that origin. +// Nothing tested the agreement, so nothing could notice the coincidence -- and +// the moment the origin has to move, the registered URI and the one the app +// actually sends diverge, and the OP rejects the request as unregistered. +// +// The origin has to move. OpenID Connect Dynamic Client Registration 1.0 +// section 2: "Web Clients using the OAuth Implicit Grant Type MUST only +// register URLs using the https scheme as redirect_uris; they MUST NOT use +// localhost as the hostname." +void main() { + // The origin an OIDC-conformant web RP must be served from: https, and not + // localhost. Both halves of the section 2 sentence. + final conformantBase = Uri.parse('https://rp.oidc.test:22433/'); + + group('resolveWebRedirectUri follows the serving origin', () { + test('an https non-localhost origin is carried through', () { + expect( + resolveWebRedirectUri('redirect.html', base: conformantBase), + Uri.parse('https://rp.oidc.test:22433/redirect.html'), + ); + }); + + test('the legacy http localhost origin still resolves', () { + // The default local-development origin must keep working; this change is + // about not hardcoding it, not about abandoning it. + expect( + resolveWebRedirectUri( + 'redirect.html', + base: Uri.parse('http://localhost:22433/'), + ), + Uri.parse('http://localhost:22433/redirect.html'), + ); + }); + + test('a deep SPA route resolves against its directory, not the route', () { + expect( + resolveWebRedirectUri( + 'redirect.html', + base: Uri.parse('https://rp.oidc.test:22433/app/secret-route'), + ), + Uri.parse('https://rp.oidc.test:22433/app/redirect.html'), + ); + }); + + test('query parameters are merged onto the resolved URI', () { + // The front-channel logout URI is the same page with a marker query. + expect( + resolveWebRedirectUri( + 'redirect.html', + base: conformantBase, + queryParameters: {'requestType': 'front-channel-logout'}, + ), + Uri.parse( + 'https://rp.oidc.test:22433/redirect.html' + '?requestType=front-channel-logout', + ), + ); + }); + + test('an absolute configured URI overrides the base', () { + expect( + resolveWebRedirectUri( + 'https://elsewhere.test/cb.html', + base: conformantBase, + ), + Uri.parse('https://elsewhere.test/cb.html'), + ); + }); + + test('a non-http base is rejected by name, not by StateError', () { + // On every non-web platform Uri.base is a file:// URI, and Uri.origin + // throws a bare "Bad state: Origin is only applicable schemes http and + // https" from dart:core -- a message that names neither this function nor + // the caller that passed the wrong base. That is exactly what reached CI: + // the front-channel logout URI was derived unconditionally, so all five + // native conformance jobs died inside dart:core with no indication that + // an origin-derived web URI was the cause. + // + // Resolving against a file:// base is never meaningful here, so refuse it + // where the caller can see why. + expect( + () => resolveWebRedirectUri( + 'redirect.html', + base: Uri.parse('file:///home/runner/work/oidc/example'), + ), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + allOf(contains('http'), contains('file')), + ), + ), + ); + }); + + test('an empty configured URI is rejected', () { + expect( + () => resolveWebRedirectUri(' ', base: conformantBase), + throwsA(isA()), + ); + }); + }); + + group('the harness and the app cannot drift apart', () { + // The regression lock. Before this existed the two derivations were + // separate literals; re-introducing a second one breaks this test rather + // than surfacing as an unregistered-redirect_uri failure against a live + // provider, minutes into a conformance run. + test('both derive the same redirect URI from one origin', () { + expect( + harnessWebRedirectUri(base: conformantBase), + appWebRedirectUri(base: conformantBase), + ); + }); + + test('and neither is pinned to the old hardcoded origin', () { + expect( + harnessWebRedirectUri(base: conformantBase).toString(), + isNot(contains('localhost')), + ); + }); + }); +} diff --git a/packages/oidc/example/tool/patrol_tally.dart b/packages/oidc/example/tool/patrol_tally.dart new file mode 100644 index 00000000..673839e3 --- /dev/null +++ b/packages/oidc/example/tool/patrol_tally.dart @@ -0,0 +1,308 @@ +// Reconciles a patrol test run against patrol's OWN printed summary, and fails +// the job when the two disagree. +// +// WHY THIS EXISTS +// +// `patrol_log_plus` counts a test into `Total` as soon as it announces `start` +// (`patrol_log_reader.dart:69` -- `int get totalTests => _singleEntries.length`, +// appended on `TestEntryStatus.start`). The three result buckets are +// independent filters over that same list, matching `success`, `failure` and +// `skip` respectively (`:72`, `:76`, `:83`). A test whose closing entry never +// arrives keeps `openingTestEntry.status` +// (`patrol_single_test_entry.dart:33-34`), which is `start` -- and `start` +// matches NONE of the three buckets. The vanished test is therefore counted in +// `Total` and nowhere else. +// +// The consequence is not merely cosmetic. `failedTestsCount == 0` makes +// `web_test_backend.dart:649-654` take the `else if (exitCode != 0)` branch, so +// the run ends with the misleading "Playwright process exited unexpectedly with +// code 1" instead of "Some tests failed." -- and no test is ever named. +// +// Observed in the web job (run 90178636000): `Total: 13, Successful: 11, +// Failed: 0, Skipped: 0`, exit 1, and the only trace of the two lost profiles +// was that their `🧪` start markers were adjacent lines with nothing between +// them. Naming them cost a re-run of a 26-minute job. +// +// No code in patrol compares `total` against `successful + failed + skipped`. +// This does, over patrol's own stdout, so a test that starts and never reports +// counts as a FAILURE and says which one it was. +// +// Usage (CI): +// dart run tool/patrol_tally.dart --minimum-tests +// Exits 1 and prints `::error::` annotations when the run does not reconcile. + +import 'dart:io'; + +/// Strips SGR escape sequences. patrol colours the file path and the duration +/// on every closing line (`AnsiCodes.gray`), and writes cursor-clearing codes +/// when `clearTestSteps` is on, so raw CI stdout is not plain text. +final _ansi = RegExp(r'\x1B\[[0-9;]*[A-Za-z]'); + +/// patrol's own lifecycle markers (`patrol_log_plus/lib/src/emojis.dart`). +const _startMarker = '🧪'; +const _successMarker = '✅'; +const _failureMarker = '❌'; +const _skipMarker = '⏩'; + +/// The outcome of reconciling one patrol run. +class PatrolTallyResult { + PatrolTallyResult({ + required this.total, + required this.successful, + required this.failed, + required this.skipped, + required this.started, + required this.reported, + required this.errors, + }); + + /// The four numbers patrol printed, or null when it printed no summary. + final int? total; + final int? successful; + final int? failed; + final int? skipped; + + /// Test names seen on a `🧪` line. + final List started; + + /// Test names seen on a `✅` / `❌` / `⏩` line. + final List reported; + + /// Human-readable reasons this run must not be treated as green. Empty means + /// the run reconciles. + final List errors; + + /// `successful + failed + skipped` -- the tests patrol actually placed in a + /// bucket. + int? get accountedFor => + (successful == null || failed == null || skipped == null) + ? null + : successful! + failed! + skipped!; + + /// Tests counted in `Total` that landed in no bucket. Anything above zero is + /// a test that started and never reported. + int? get unaccountedFor { + final counted = accountedFor; + return (total == null || counted == null) ? null : total! - counted; + } + + /// The names of the tests that started and never reported. + /// + /// Best effort: it is empty when patrol was run with the lifecycle lines + /// hidden. [unaccountedFor] is the authoritative signal; this only attaches + /// names to it. + List get unreported { + final closed = reported.toSet(); + return started.where((name) => !closed.contains(name)).toList(); + } +} + +/// Normalizes a test name so a `🧪` line and its matching `✅`/`❌` line +/// compare equal. +/// +/// They are NOT printed the same way, and which shape applies is decided by the +/// marker, not by the text. `TestEntry.pretty()` branches on `isFinished`, +/// which is true for `success` and `failure` ONLY: +/// +/// 🧪 / ⏩ -> ' ' -- keeps the leading file token +/// ✅ / ❌ -> ' ' -- `nameWithPath` DROPS the first +/// whitespace-delimited token and +/// appends `(/.dart)` +/// +/// and the closing line additionally carries a `(s)` duration. So the file +/// token is stripped from the first shape and never from the second; guessing +/// from the text instead would eat the real first word of any test name whose +/// second word carries the punctuation the guess keyed on. +String _normalizeName(String raw, {required bool hasFileToken}) { + var name = raw.replaceAll(_ansi, '').trim(); + // Drop the trailing `(...)` groups the closing line appends: the source path + // and the execution time. Only parenthesised groups at the very end are + // removed, so a test whose own name contains parentheses survives. + final trailingGroup = RegExp(r'\s*\([^()]*\)$'); + while (trailingGroup.hasMatch(name)) { + name = name.replaceFirst(trailingGroup, '').trim(); + } + if (hasFileToken) { + final firstSpace = name.indexOf(' '); + // Only when something is left afterwards: a bare, unprefixed name must not + // be normalized down to the empty string. + if (firstSpace > 0 && firstSpace + 1 < name.length) { + name = name.substring(firstSpace + 1).trim(); + } + } + return name; +} + +int? _lastCount(List lines, String label) { + final pattern = RegExp('$label:\\s*([0-9]+)'); + int? found; + for (final line in lines) { + final match = pattern.firstMatch(line); + if (match != null) { + found = int.parse(match.group(1)!); + } + } + return found; +} + +/// Reconciles [log] -- the captured stdout of one `patrol_plus test` run. +/// +/// [minimumTests] is a ratchet: patrol reports a run that executed nothing as a +/// clean pass, so a floor is the only way a green result can mean "the tests +/// ran". It mirrors the Android job's existing `Total:` gate. +PatrolTallyResult reconcilePatrolTally( + String log, { + required int minimumTests, +}) { + final lines = log.split('\n'); + + final started = []; + final reported = []; + for (final rawLine in lines) { + final line = rawLine.replaceAll(_ansi, '').trim(); + // The summary block reuses the same emojis as prefixes for its counters + // ("✅ Successful: 11"), so a line that carries a `