Skip to content

Implement V2 (HAL-based) SSPR flow for native auth - #3057

Open
spetrescu84 wants to merge 87 commits into
sedemche/native-auth-v2_releasefrom
spetrescu/native-auth-v2-sspr
Open

Implement V2 (HAL-based) SSPR flow for native auth#3057
spetrescu84 wants to merge 87 commits into
sedemche/native-auth-v2_releasefrom
spetrescu/native-auth-v2-sspr

Conversation

@spetrescu84

Copy link
Copy Markdown
Contributor

Overview

This PR implements the V2 (server-driven / HAL-based) self-service password reset (SSPR) flow for native auth on iOS and macOS.

Unlike the V1 client-driven flow, V2 is driven by the server's HAL responses: the client follows _links relations returned in each response to decide the next network call, rather than hard-coding the sequence of endpoints.

What's included

Flow orchestration

  • MSALNativeAuthFlowController — orchestrates the SSPR flow (start → challenge → verify code → update password → completion) by mapping validated responses onto the next required state.
  • MSALNativeAuthFlowResponseDispatcher — routes validated responses to the appropriate public state / delegate.
  • MSALNativeAuthFlowContinuationState, MSALNativeAuthFlowInternalState, MSALNativeAuthFlowControllerResponse, MSALNativeAuthFlowResult — internal state plumbing carrying continuation tokens and resolved HAL links between layers.
  • MSALNativeAuthFlowControlling — controller protocol seam.

HAL networking

  • HALResource, MSALNativeAuthHALResponse, MSALNativeAuthV2HALAction, MSALNativeAuthV2HALResponseSerializer — HAL response model and serialization.
  • MSALNativeAuthV2ResponseValidator / MSALNativeAuthV2ValidatedResponses — validate HAL responses into typed, actionable outcomes (code required, update required, poll in progress, ready to complete, error).
  • MSALNativeAuthV2ResponseErrorHandler, MSALNativeAuthV2HrefURLResolver, MSALNativeAuthV2LinkRelation, MSALNativeAuthV2Endpoint — error handling and _links resolution.

Request building (parameters-driven, mirroring V1)

  • MSALNativeAuthV2RequestProvider / MSALNativeAuthV2RequestConfigurator — build requests from typed parameter objects.
  • Typed parameter classes: MSALNativeAuthV2EntryParameters, MSALNativeAuthV2HrefParameters, MSALNativeAuthV2AuthorizeChallengeStart/ContinueParameters, MSALNativeAuthV2TokenParameters, MSALNativeAuthV2RequestTarget, MSALNativeAuthV2Requestable.
  • MSALNativeAuthV2RequestBody / MSALNativeAuthV2RequestBodyKey — typed request body.

Token handling & telemetry

  • MSALNativeAuthTokenCacher, MSALNativeAuthTokenRequestHandling — extracted token request/caching seam reused by the V2 completion step.
  • Telemetry api ids and operation types for the SSPR V2 operations.

Tests

  • Unit tests for the flow controller, dispatcher, response validator, request provider, parameters, HAL serializer, href resolver, error handler, and flow error mapping.

Notes

  • Public API surface for native auth is unchanged in shape; V2 plugs in behind the existing entry points.
  • Built for iOS and macOS.

spetrescu84 and others added 30 commits June 30, 2026 10:26
Introduce a unified, server-driven (HAL-JSON) Native Auth V2 surface that
mirrors V1's internal architecture (controller / request-provider / validator /
state / delegate / factory) while reusing the shared MSIDHALResource/MSIDHALLink
parsing types from IdentityCore.

- Public surface: MSALNativeAuthFlowError/Action/Delegate/FlowState.
- Unified controller drives the full SSPR sequence (authorize-challenge ->
  resetpassword start -> challenge -> verify -> update -> poll ->
  authorize-challenge -> token). signUp/signIn V2 return notImplemented.
- Network layer builds MSIDHttpRequest directly; HAL serializer + error handler
  funnel any HTTP status (incl. the bootstrap 401) into the validator.
- Public app gains resetPasswordV2/signUpV2/signInV2; V1 signUp/signIn/
  resetPassword are deprecated in favour of the V2 entry points.
- Unit tests + mocks for the V2 controller and response validator.
- Bump IdentityCore submodule to include the HAL serializer types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dc issues
url encoding
generic swiftlint rules for AI
…ering, comment cleanup

Fail fast on missing bootstrap links, make provider hrefs non-optional,
consistently order request-provider methods, and strip step-numbered /
V1-comparison narration comments.

- Controller entry points (signUp/signIn/resetPassword) now resolve their
  required bootstrap link (sign_up/sign_in/reset_password) right after the
  authorize-challenge guard and fail early via missingLinkFailure(...) with an
  "improper response" error, instead of passing an optional href that only
  fails on the next request. Renamed the local bootstrapToken -> continuationToken.
- MSALNativeAuthV2RequestProviding: signUpStart/signInStart/resetPasswordStart
  now take a non-optional href (always .href(href)); resetPasswordStart uses its
  bootstrap reset_password link. Removed the now-dead signInStart/signUpStart/
  resetPasswordStart cases from MSALNativeAuthV2Endpoint.
- Reordered all V2 request-provider methods (protocol + impl) to
  signUp -> signIn -> resetPassword -> HAL methods -> authorize-challenge
  (start/continue) -> token (last). Paren-aligned multi-parameter signatures.
- Removed step-numbered ("Step 1 - ...", "(step 7)") and V1-comparison
  comments across the V2 layer; comments now describe what each piece does
  without implying a fixed call order.
- Updated the request-provider mock and one reset-password test to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…2 validator

Move required-link validation into the V2 response validator so a flow fails as
soon as the server response is missing the link the next step needs, instead of
failing later on a subsequent request.

- Thread MSALNativeAuthV2FlowType from each entry point (signUp/signIn/
  resetPassword) down to validateAuthorizeChallenge. Each flow now declares its
  flowType exactly once; downstream steps derive it from the flow type or the
  continuation state.
- Resolve the single flow-specific entry link in the validator: .continuationToken
  now carries a non-optional href instead of a links dictionary, and a missing
  link returns a descriptive .error immediately.
- Make interaction-response hrefs non-optional and guard each required link in
  validateInteraction, so the controller can no longer receive a nil href.
- Expose MSALNativeAuthV2FlowType.link (CaseIterable) as the single source of the
  sign_up/sign_in/reset_password relations; drop the duplicated serializer list.
- Remove dead concatenated link spellings (signup/signin/resetpassword/
  submitattributes); sign_up/sign_in/reset_password are the only valid relations.
- Rename the authorize-challenge step off "bootstrap" for clarity.
- Update the validator mock and add fail-fast regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…clarity

- Rename ambiguous token2 to challengeContinuationToken in the sign-in and
  reset-password start paths.
- Thread the authorization-challenge hint through sign-in so codeRequired and
  activationRequired fall back to the masked destination when the challenge
  response omits sentTo, matching the reset-password behavior.
- Split selectAuthMethod into explicit JIT (enroll) vs MFA (challenge) branches,
  each owning its own guard, error message, and telemetry id so a missing link
  is reported against the correct flow.
- Split submitChallenge into JIT (activate) vs MFA (verify) so a missing link
  reports exactly which link was absent instead of "verify/activate".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…kenCacher

Both native-auth token controllers now delegate token-response caching to a
single MSALNativeAuthTokenCacher helper (clear existing account, save tokens,
validate account). Account validation is injected as a closure so each
controller keeps using its own validator, restoring the post-save account
validation that was missing on the server-driven flow path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Share token-request handling between the token controller and the V2 flow
controller via a MSALNativeAuthTokenRequestHandling protocol with a default
implementation, keeping performTokenRequest off the base controller. Extract
the request interceptor bridge into a single shared type used by both the V1
and V2 request configurators. Route V2 response errors through isError so the
server error is surfaced up the chain in all validator paths, and drop unused
HALResource link accessors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the isError computed property and bind response.error directly in the
V2 response validator, dropping the extra flowError overload and its defensive
nil branch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Auto-submit the email/password (and any pre-supplied attributes) the app
provides at sign-up start when the server issues collectAttributes for them,
keeping the values private (internal continuation state, never surfaced to the
app, never logged). The full value set is retained for the whole flow; only the
attributes the server requests in each step are sent.

Track auto-submitted attribute ids and, if the server re-requests an attribute
already submitted (e.g. after a validation failure), surface a general error to
the app instead of resending in a loop. The error names the attribute id only,
never the value.

Also fix the /auth/methods/{type}/{id}/verify endpoint to send the code in the
`otp` field (not `code`) for sign-in and sign-up, matching the server contract
(previously returned AADSTS90023). MFA verify uses `verify`/`otp`; the JIT
`activate` link keeps `submitCode`/`code`.

Adds regression tests for the autofill happy path, the repeat-attribute error,
and the otp verify field for sign-in/sign-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e612cb1a-744b-4c23-8156-b1ac27d3ccad
Replace scattered string literals in the Native Auth V2 (HAL) request/response
paths with pure String-backed enums, mirroring V1's MSALNativeAuthRequestParametersKey
style (cases only, no static helpers). Wire format is preserved byte-for-byte.

- Add MSALNativeAuthV2RequestBodyKey (camelCase HAL body keys)
- Add MSALNativeAuthV2LinkRelation (HAL _links relation names)
- Add MSALNativeAuthV2HALAction (the response `action` verb)
- Route request bodies, link lookups, and the validator switch through the enums
- Split per-method links into a typed methodLinks collection on the
  continuation state (drop the composite "method:<id>" key)
- Trim comments that merely restate the code
- Fix pre-existing test-target breaks (mock param types, flow scenario case)
- Add build/test guidelines to .clinerules/AGENTS.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1950ebbe-35fe-4325-861d-95117a60f4c2
Replace the hand-assembled [AnyHashable: Any] body dictionaries in the V2 HAL
request provider with a typed MSALNativeAuthV2RequestBody. Each field maps to a
MSALNativeAuthV2RequestBodyKey and is emitted only when set, so call sites state
just the fields an operation carries (e.g. a password submit sets `password`,
a poll sets nothing extra). The generated body dictionary is unchanged, so the
wire format is preserved.

- Add MSALNativeAuthV2RequestBody (typed continuationToken + optional fields)
- MSALNativeAuthV2HrefParameters now carries the typed body and derives `body`
- Route the 8 HAL provider methods through the typed body
- Register the new file in project.pbxproj

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1950ebbe-35fe-4325-861d-95117a60f4c2
…(V1-style)

Thread MSIDRequestContext through the V2 response validator's three
validate methods (mirroring V1 validators) and emit decision/error log
statements. Server error messages and transport failures are masked via
MSALLogMask.maskPII; no tokens, continuation tokens, or hints are logged.
Updates the flow controller call sites, the validator mock, and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1950ebbe-35fe-4325-861d-95117a60f4c2
Comment thread MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift Outdated
Comment thread MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResult.swift Outdated
Comment thread MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResult.swift Outdated
Comment thread MSAL/src/native_auth/network/v2/MSALNativeAuthV2Endpoint.swift Outdated
Comment thread MSAL/src/native_auth/network/v2/MSALNativeAuthV2RequestBody.swift Outdated
Comment thread MSAL/src/native_auth/public/MSALNativeAuthPublicClientApplication.swift Outdated
spetrescu84 and others added 9 commits July 27, 2026 14:24
The V2 'validator' returns a response model rather than a valid/invalid
result, so 'parser' better reflects its behavior. Renames the protocol,
class, validated-response enums, methods, files and enclosing group from
validator/Validated to parser/Parsed. V1 validators and account
validation are unaffected.

Also fixes a latent context: context() call in the browserRequired
mapInteraction test that had never been compiled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ecf13a9d-6bea-4c2f-834e-ef32ed967500
let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines)

// Absolute href: use as-is (still append the data-center parameter).
if let absolute = URL(string: trimmed), let scheme = absolute.scheme, scheme.hasPrefix("http") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since v2 is server-driven and we POST the continuation token — and for verify/updatePassword the OTP and the new password — to whatever _links href comes back, what constrains the host we send those to? In the absolute-href branch we take the server URL as-is (scheme.hasPrefix("http")) with no check that its host matches authorityURL. Two things worth confirming: (1) should we validate the absolute href's host against the configured authority host before following it, so a tampered/misconfigured response can't redirect credentials to another host? and (2) hasPrefix("http") also accepts http:// — should we require https here so we never downgrade a code/password to cleartext? I get that today DC returns hrefs on the same host and TLS protects transit, but for a credential-bearing flow the host allow-list feels like cheap defense-in-depth. (Note: the relative branch already reconstructs against the authority, so this is only the absolute path.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback, we are trying to get the API team to return just the API endpoint, like oauth2/v2.0/authorize-challenge and then we would be able to use the v1 URL Resolver so this class would go away. Added a //TODO a the start of the class to change it once we clarify conversation

)
}

guard case .pollInProgress(var pollToken, let pollHref) = updateResult else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step guards only on .pollInProgress and sends everything else to interactionFailure(..., newState: nil). But a weak-password rejection at update maps to .invalidPassword (passwordTooWeak), and here that would come back with newState: nil — so the user can't retry the password and has to restart the whole reset. mapInteraction deliberately keeps invalidPassword/invalidCode recoverable (and you have test_mapInteraction_error_invalidPassword_isRecoverable for it), but submitNewPassword bypasses mapInteraction, so that recoverability is lost on this path. Could we route the update result through the same recoverable-state handling (or at least pass recoverableState: state for .invalidPassword)? Relatedly, if update ever returns .readyToComplete/.updateRequired directly instead of a poll link, we'd also treat it as a generic failure — is polling always guaranteed by the API here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch re the unrecoverable state, update to first check if we have any error and fail based on type
The API contract does say that update must return poll after

spetrescu84 and others added 2 commits July 29, 2026 15:16
Convert MSALNativeAuthV2LinkRelation from an enum to a RawRepresentable
struct with one constant per extension, so the type stays open for
extension without core edits. Remove the enroll, register, activate, and
submitAttributes relations that never appear in the observed responses.

Update the flow controller tests to key links by typed relation constants
instead of raw strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ecf13a9d-6bea-4c2f-834e-ef32ed967500
@spetrescu84
spetrescu84 force-pushed the spetrescu/native-auth-v2-sspr branch from fb4abe2 to 9edc2d3 Compare July 29, 2026 15:53
let username: String?
/// Scopes (caller-requested merged with the default OIDC scopes) to request on the final
/// `/token` exchange. Threaded through every step.
let scopes: [String]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have a new callback (state) to request scopes just before we call /token enpoint? This will also allow us to keep same SSPR params in V2 as we had in V1

let flowScenario: MSALNativeAuthFlowScenario
let continuationToken: String?
let links: [MSALNativeAuthV2LinkKey: URL]
let username: String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when do we need it? Also in what flows it is needed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants