Skip to content

feat(oidc-client): implement authorize API #344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open

Conversation

cerebrl
Copy link
Collaborator

@cerebrl cerebrl commented Jul 9, 2025

Suggestions for using stronger design patterns for authorize. Treat this as non-working pseudocode.

introduce the oidc-client with the authorize api. authoirize handles the
calling of authorize routes based on the well-known config.

when a p1 env is detected, it will use a post request
otherwise, a background request is done via a hidden iframe
Copy link

changeset-bot bot commented Jul 9, 2025

⚠️ No Changeset found

Latest commit: f35d74a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link

nx-cloud bot commented Jul 9, 2025

View your CI Pipeline Execution ↗ for commit f35d74a

Command Status Duration Result
nx affected -t build typecheck lint test e2e-ci ❌ Failed 1m 39s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2025-07-16 06:43:45 UTC

@cerebrl cerebrl force-pushed the justin_oidc-mods branch 2 times, most recently from 0a12961 to abc572c Compare July 10, 2025 21:39
@cerebrl cerebrl force-pushed the justin_oidc-mods branch from abc572c to 0b1e1dd Compare July 14, 2025 18:37
@cerebrl cerebrl changed the base branch from oidc-client-authorize to main July 16, 2025 06:42
@cerebrl cerebrl changed the title feat(oidc-client): implement stronger patterns feat(oidc-client): implement authorize API Jul 16, 2025
) {
const buildAuthorizeRequestµ = buildAuthorizeOptionsµ(wellknown, config, options).pipe(
Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
(effect) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Any specific reason we are using this arrow function style here?

Copy link
Collaborator

@ryanbas21 ryanbas21 left a comment

Choose a reason for hiding this comment

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

some initial looks over will look again

const buildAuthorizeRequestµ = buildAuthorizeOptionsµ(wellknown, config, options).pipe(
Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
(effect) => {
return Micro.matchEffect(effect, {
Copy link
Collaborator

Choose a reason for hiding this comment

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

i'd be curious if just using tapError and tap would be better fits here

https://effect-ts.github.io/effect/effect/Micro.ts.html#taperror

https://effect-ts.github.io/effect/effect/Micro.ts.html#tap

tapError would only run on error channels, and tap on success channels

return Micro.gen(function* () {
if ('authorizeResponse' in response) {
log.debug('Received authorize response', response.authorizeResponse);
return yield* Micro.succeed(response.authorizeResponse as AuthorizeSuccessResponse);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the succeed here is redundant in a gen it should implicitly return a Micro here

}
log.error('Error in authorize response', response);
const errorResponse = response as { error: string; error_description: string };
return yield* createAuthorizeErrorµ(errorResponse, wellknown, config, options);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would we want to Fail out of this effect here? This keeps the error in the success channel, is that what we want?

Micro/Effect should always keep Errors are values, so i think it would make sense to Fail out of it.

* redirect based server supporting iframes. An example would be PingAM.
*/
return authorizeIframeµ(url, config).pipe(
Micro.flatMap((response) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think given the below changes this could just be a map now?


export function authorizeFetchµ(url: string) {
return Micro.tryPromise({
try: async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nothing inherently wrong with this, just going to say how I usually write this.

Because tryPromise takes a Promise, I find it easier to just pass in fetch with no awaiting. then break down the steps following it in their own sequences. This allows for more granular error handling in my opinion.

Micro.tryPromise({
  try: (signal) => fetch('....', { signal }),
  catch: (e) => new MyFetchError()
  }).pipe(
    Micro.flatMap(res => Micro.tryPromise({
      try: () => res.json(),
      catch: (err) => new ResJsonErr()
    })));

(not sure we can use Data if we use Micro, but it's just a helper around making errors, not required in the below example)
https://effect.website/play#5b10175aea48

}

return {
error: 'Authorization Notwork Failure',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Notwork spelling?


export function authorizeIframeµ(url: string, config: OidcConfig) {
return Micro.tryPromise({
try: async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

np; unneccessary body?

return err;
}

const result = await authorizeµ(wellknown, config, log, options);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm curious if it may be more clear to runPromiseExit here where we explicitly want to run the effect.

this keeps the actual authorize effect self-contained and composable later on?

export async function authorizeµ(
wellknown: WellKnownResponse,
config: OidcConfig,
log: CustomLogger,
Copy link
Collaborator

Choose a reason for hiding this comment

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

this would be a good use case for a service (like we discussed) so we don't need to pass around the logger throughout the api

*/
return authorizeFetchµ(url).pipe(
Micro.flatMap((response) => {
return Micro.gen(function* () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I actually think this entire gen is unneccessary since you're just returning effects you can do that out of flatMap and no need for gen

import type { OidcConfig } from './config.types.js';
import { AuthorizeSuccessResponse } from './authorize.request.types.js';

export async function authorizeµ(
Copy link
Collaborator

@ryanbas21 ryanbas21 Jul 16, 2025

Choose a reason for hiding this comment

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

export async function authorizeµ(
  wellknown: WellKnownResponse,
  config: OidcConfig,
  log: CustomLogger,
  options?: GetAuthorizationUrlOptions,
) {
  return buildAuthorizeOptionsµ(wellknown, config, options).pipe(
    Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
    Micro.tap((url) => log.debug('Created authorization URL', { url })),
    /**
     * This probably needs to change because `log` doesn't reutrn an effect / micro and we want to return one from here
     * really `log` needs to be wrapped in a service i guess
     */
    Micro.tapError((url) => Micro.fail(log.error('Created authorization URL', { url }))),
    Micro.flatMap(([url, config, options]) => {
      if (options.responseMode === 'pi.flow') {
        /**
         * If we support the pi.flow field, this means we are using a PingOne server.
         * PingOne servers do not support redirection through iframes because they
         * set iframe's to DENY.
         */
        return authorizeFetchµ(url).pipe(
          Micro.flatMap((response) => {
            if ('authorizeResponse' in response) {
              log.debug('Received authorize response', response.authorizeResponse);
              return Micro.succeed(response.authorizeResponse as AuthorizeSuccessResponse);
            }
            log.error('Error in authorize response', response);
            const errorResponse = response as { error: string; error_description: string };
            return Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options));
          }),
        );
      } else {
        /**
         * If the response mode is not pi.flow, then we are likely using a traditional
         * redirect based server supporting iframes. An example would be PingAM.
         */
        return authorizeIframeµ(url, config).pipe(
          Micro.flatMap((response) => {
            if ('code' in response && 'state' in response) {
              log.debug('Received authorization code', response);
              return Micro.succeed(response as unknown as AuthorizeSuccessResponse);
            }
            log.error('Error in authorize response', response);
            const errorResponse = response as { error: string; error_description: string };
            return Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options));
          }),
        );
      }
    }),
    /**
     * This could be moved out of this authorize function
     */
    Micro.runPromiseExit,
  );
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

did a rewrite, feel free to pick and choose but really the key parts are undid a few unneccessary layers of Micro and then explicitly Fail when we should return an error.

Copy link
Collaborator

@ryanbas21 ryanbas21 Jul 16, 2025

Choose a reason for hiding this comment

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

one smaller re-write

export function authorizeµ(
  wellknown: WellKnownResponse,
  config: OidcConfig,
  log: CustomLogger,
  options?: GetAuthorizationUrlOptions,
) {
  return buildAuthorizeOptionsµ(wellknown, config, options).pipe(
    Micro.flatMap(([url, config, options]) => createAuthorizeUrlµ(url, config, options)),
    Micro.tap((url) => log.debug('Created authorization URL', { url })),
    Micro.tapError((url) => Micro.sync(() => log.error('Created authorization URL', { url }))),
    Micro.flatMap(([url, config, options]) => {
      if (options.responseMode === 'pi.flow') {
        return authorizeFetchµ(url).pipe(
          Micro.map((response) => {
            if ('authorizeResponse' in response) {
              return response.authorizeResponse as AuthorizeSuccessResponse;
            }
            const errorResponse = response as { error: string; error_description: string };
            return Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options));
          }),
          Micro.tap((response) => log.debug('Received authorize response', response)),
          Micro.tapError((response) =>
            Micro.sync(() => log.error('Error in authorize response', response)),
          ),
        );
      } else {
        return authorizeIframeµ(url, config).pipe(
          Micro.map((response) => {
            if ('code' in response && 'state' in response) {
              return response as unknown as AuthorizeSuccessResponse;
            }
            const errorResponse = response as { error: string; error_description: string };
            return Micro.fail(createAuthorizeErrorµ(errorResponse, wellknown, config, options));
          }),
          Micro.tap((response) => log.debug('Received authorize response', response)),
          Micro.tapError((response) =>
            Micro.sync(() => log.error('Error in authorize response', response)),
          ),
        );
      }
    }),
  );
}

}),
);

return Micro.runPromiseExit(buildAuthorizeRequestµ);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thinking about this more, wouldn't this be against our pattern of running effects at the edge? i'd think we'd want this to be as close to the edge of the program as possible.

I'd advocate this happen at the top level if possible

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I agree with all of your comments. This one especially. I was actually going to move this to the top file, but didn't get around to it.

const authResponse = oidcClient.authorize.background(); // Returns code and state if successful, error and Auth URL if not
const authUrl = oidcClient.authorize.url(); // Returns Auth URL or error

// Tokens API
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do the tokens api and user api exist on the OIDC client yet? Didn't see them on the client store

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, this is just the authorize feature. Token exchange is found here: #348. The other token related methods are not implemeneted yet.

@@ -116,7 +116,7 @@ export default function iFrameManager() {
// 1. Check for Error Parameters
if (hasErrorParams(searchParams, errorParams)) {
cleanup();
reject(parsedParams); // Reject with all parsed params for context
resolve(parsedParams); // Reject with all parsed params for context
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not reject and catch it with a Micro.fail in authorizeIframeµ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think this is reasonable now that the code is more mature. I'll reevaluate this today.

@@ -0,0 +1,81 @@
import { CustomLogger } from '@forgerock/sdk-logger';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Missing copyright in some files

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

Successfully merging this pull request may close these issues.

3 participants