Skip to content

Conversation

gturpin-dev
Copy link
Contributor

@gturpin-dev gturpin-dev commented Aug 6, 2025

Hey 👋

Wanted to give a first-party implementation of OAuth support ( #1351 ) as discussed in discord
So it's different from Authentik as it is first-party PR and it does not uses phpleague wrapper

  • It uses HttpClient provided by the container to make calls to providers
  • It currently have support for Github, Google and interfaces only for OAuth 2.0 spec and not 1.0

It also differ because I assume having a database implementation is opinionated so I think we should provide tools and docs to help doing this with less friction as possible with our auth package for example
But we shouldn't assume that a specific field on database is here to store refresh tokens or something else

That said, It's a draft PR as this hasn't been tested yet with a lot of providers, some adjustments may be needed.
Also, there aren't any tests yet, I don't know well how to test this whole workflow, as the majority of the work include userland things, plus I wouldn't go into it before discussing about the feature before

So, for a Tempest app to use this implementation, it needs a user model ( I've tested it with builtin auth installer ).
Here's a controller that handle all parts of the flow :

final class GoogleOAuthController
{
    #[Get('/auth/google')]
    public function redirect(
        #[Tag(BuiltInOAuthProvider::GOOGLE->value)] OAuthManager $OAuthManager,
    ): Response
    {
        return new Redirect($OAuthManager->generateAuthorizationUrl());
    }

    #[Get('/auth/google/callback')]
    public function callback(
        Request $request,
        #[Tag(BuiltInOAuthProvider::GOOGLE->value)] OAuthManager $OAuthManager,
        Authenticator $authenticator,
        Session $session
    ): Response
    {
        if ( ($requestState = $request->get('state')) === null ) {
            throw new InvalidStateException('Missing state parameter in request');
        }

        if ( ($sessionState = $session->consume($OAuthManager->stateSessionSlug)) === null ) {
            throw new InvalidStateException('Missing state in session');
        }

        if ( $requestState !== $sessionState ) {
            throw new InvalidStateException('State mismatch between request and session');
        }

        $accessToken = $OAuthManager->generateAccessToken(
            code: $request->get('code') ?? throw new InvalidCodeException('Missing code parameter in request'),
        );

        $userData = $OAuthManager->fetchUserDataFromToken($accessToken);
        $user = $this->findOrCreateUser($userData);
        $authenticator->login($user);

        return new Redirect('/auth/dashboard');
    }

    private function findOrCreateUser(OAuthUserData $userData): User
    {
        $user = User::select()
            ->where('email == ?', $userData->email)
            ->first();

        if ( $user === null ) {
            $user = new User(
                name: $userData->name,
                email: $userData->email,
            )
                ->setPassword(str()->random(40)->toString()) // Random password as OAuth users don't need a password
                ->save();
        }

        return $user;
    }
}

To summarize the workflow
We first call /auth/google which will redirect to the given provider ( all built-in providers are handled by Initializers to make it easier to add an unsupported one, I have an example that can be added in the docs )
The provider then will call our /auth/google/callback route which will generate the access token using the code/state
Then we will fetch the user data and create it if not exist yet and login him
After that I redirect to an example dashboard route that display the current user

What's missing now :

  • Storing state in session and validate it later on
  • Other providers to include examples and enforcing interfaces genericity and security specific flows ( such as JWT, nonce etc... )
  • Better error handling during the whole process
  • Tests

If you like the approach I can dig the above points

@gturpin-dev gturpin-dev changed the title feat(auth): Adds basic OAuth support feat(auth): adds basic oauth support Aug 6, 2025
@gturpin-dev gturpin-dev force-pushed the feat/auth/oauth-sso branch from c1aa75a to acf1b89 Compare August 6, 2025 12:57
@innocenzi
Copy link
Member

I very much agree with the philosophy: we should provide only primitives with almost no opinion regarding the final implementation

@shaffe-fr
Copy link
Contributor

shaffe-fr commented Aug 12, 2025

Great work on this implementation!

This OAuth feature should support provider-specific security checks to strengthen protection against token replay, tampering, and other attacks.

  • In generateAuthorizationUrl:
    Add extra query parameters (e.g., a nonce stored in the session) to support these checks.

  • In getUserDataFromResponse or a dedicated security method:
    Validate the refresh/ID token by:

    • Decoding the JWT and verifying its payload
    • Checking the known public key (kid)
    • Matching the client ID (aud)
    • Verifying the issuer URL (iss)
    • Matching the nonce with the session value (nonce)
    • Ensuring the token is not expired (exp)
    • Verifying token integrity (sha256(access_token) matches at_hash)
  • Session storage:
    Providers should be able to access OAuthManager info to store:

    • The state (e.g., for logout URL)
    • Expiration timeout (expires_in)
    • Refresh token

    Ideally, this should be handled in a unified and extensible way.

It would be great to build on this approach by allowing flexible, provider-specific customization as needed!

@gturpin-dev
Copy link
Contributor Author

@shaffe-fr Thanks for those great ideas 🎉

Is the nonce you mentioned the equivalent of the state validation I mentioned in the TODO ? ( I think yes, but to be sure :) )

I think all security methods you mentioned can be done depending on the provider, but I'll look into them to see if I can implement some of these to be easier to use or abstracted in requests like the stateless which prevent sending state and decoding it

FYI, I've looked into Socialite implementation to check important things and DX

@shaffe-fr
Copy link
Contributor

Yes, that’s exactly what I meant—your mention of state is closely related to the nonce concept. Some providers may require both state and nonce (or other fields) depending on their security needs.

When calling Provider::getAuthorizationQueryParameters, the provider should have access to the current state, or null if the request is stateless.

Regarding the security checks, I agree they should be handled within the provider implementation. I was thinking that adding an optional method to encourage these validations could be helpful. In the Tempest style, this might look like using a dedicated request or mapper class to encapsulate those checks cleanly.

From my experience implementing this for a client, session management can feel a bit clunky—storing stuff in session inside a getXXX method doesn’t feel very clean or intuitive. Finding a better way to manage session data or state would definitely improve maintainability and clarity.

@brendt
Copy link
Member

brendt commented Aug 14, 2025

I haven't looked through this in depth yet.

does not uses phpleague wrapper

I do wonder why not? I've been using the phpleaguea wrapper and it's really intuitive + has support for so many providers. Why start from scratch in this case?

@gturpin-dev
Copy link
Contributor Author

I do wonder why not? I've been using the phpleaguea wrapper and it's really intuitive + has support for so many providers. Why start from scratch in this case?

@brendt It was to challenge the authentik solution and then not depend to external dependency, but we can go this way either

@innocenzi
Copy link
Member

I agree that we should use the PHP League's OAuth2 client. As Brent said, there are many providers available.

We should wrap the API to make it Tempest-y, and maybe wrap the provider API while letting the ability to use PHP League wrappers (like we do for email and storage providers).

The only issue I can foresee is with the HTTP client under the hood, but I assume they provide a way to specify a PSR-compliant one

@gturpin-dev
Copy link
Contributor Author

I'll check those details ASAP 😄

@gturpin-dev
Copy link
Contributor Author

@innocenzi I've delve into the PHPLeague client, IMO there are some problems for us.

Make the DX Tempest-y means ( among other things ) that we want named parameters, I can't figure out how to do something like that for each custom providers if we don't wrap them one by one.
Using an existing third-party provider ( such as Bitbucket ) from League means that if there are custom methods based on custom securities, then we can't provide any extra DX. It also means that internally we'll make a sort of Facade to call the methods from the provider, then why not using directly the League package itself in this case ?

If we look at the given providers, we can see that there are 5 officials there https://oauth2-client.thephpleague.com/providers/league/ and a lot of third-party providers https://oauth2-client.thephpleague.com/providers/thirdparty/
Looking at some of third-party ones, a lot of them aren't active since 5+ years
Also, if we want native Tempest support for some League providers, we must require every provider package from composer.
Wouldn't it better to write some official implementations and still let the opportunity to make custom ones ( then we can add Tempest third-party packages along the time ) ?
That way, our implementation could use the League GenericProvider under the hood to get benefits of various features but still get the Tempest DX by maintaining some implementations, WDYT ?

@brendt
Copy link
Member

brendt commented Sep 12, 2025

Just wanted to pitch in here: I'll come back to this PR when 2.0 has been tagged.

@brendt brendt deleted the branch tempestphp:main September 16, 2025 11:38
@brendt brendt closed this Sep 16, 2025
@brendt brendt reopened this Sep 17, 2025
Sets up the Github Single Sign-On (SSO) authentication flow.

The changes introduce dependency injection for HttpClient, move configuration to the constructor, and generate the authorization URL based on scopes, client ID, and redirect URI.

This streamlines the authentication process.
Updates the codebase to use a more generic OAuth terminology instead of the specific SSO term.

This change involves renaming several files and namespaces to reflect the broader OAuth concept. This allows for more flexibility and easier integration with different OAuth providers.
Moves common OAuth logic into a trait for reusability.
Adds support for "expires_in" and "refresh_token" in the AccessToken data object.
Generalizes authorization parameter handling in OAuthManager.
Introduces Google OAuth provider.

This change aims to provide a more flexible and extensible
OAuth implementation.
Adds custom exceptions for OAuth flows to better handle invalid states and codes.

Stores the OAuth state in the session to validate the response and prevent CSRF attacks.
Simplifies the OAuth provider interface by removing the "Contract" suffix, promoting consistency and readability.

This change streamlines the codebase by using a more straightforward naming convention for the OAuth provider interface.
Ensures the session flash key for the OAuth state is configurable.

This change introduces a `stateSessionSlug` property, allowing customization of the session key used to store the OAuth state, improving flexibility and avoiding potential naming conflicts.
Makes the `stateSessionSlug` property public to allow easier access and modification when needed.
Adds support for built-in OAuth providers like Github and Google.

This change introduces:
- A `BuiltInOAuthProvider` enum to manage supported providers.
- A dynamic initializer for creating `OAuth2Provider` instances based on the selected provider, using environment variables for configuration.
- An `OAuthManagerInitializer` for resolving OAuthManager dependencies.

This approach simplifies OAuth integration by providing pre-configured providers and ensuring that necessary environment variables are set correctly, throwing an exception if something is missing.
@gturpin-dev
Copy link
Contributor Author

gturpin-dev commented Sep 18, 2025

SO, I've updated the PR to use PHPLeague by wrapping it to get benefits of it internally but having the Tempest DX
I also design it to create our own official providers as I suggest in last comment

Now, here's how it looks to perform OAuth now :

final class OAuthController
{
    #[Get('/auth/google')]
    public function redirect(
        #[Tag('from_env_variables')] GoogleProvider $provider // There's a built-in initializer to load providers from env
    ): Response
    {
        return new Redirect(
            $provider->generateAuthorizationUrl()
        );
    }

    #[Get('/auth/google/callback')]
    public function callback(
        #[Tag('from_env_variables')] GoogleProvider $provider,
        Request $request,
        Authenticator $authenticator,
        Session $session
    ): Response
    {
        if ( ($requestState = $request->get('state')) === null ) {
            throw new InvalidStateException('Missing state parameter in request');
        }

        if ( ($sessionState = $session->consume($provider->stateSessionSlug)) === null ) {
            throw new InvalidStateException('Missing state in session');
        }

        if ( $requestState !== $sessionState ) {
            throw new InvalidStateException('State mismatch between request and session');
        }

        $accessToken = $provider->generateAccessToken(
            code: $request->get('code') ?? throw new InvalidCodeException('Missing code parameter in request'),
        );

        $userData = $provider->fetchUserDataFromToken($accessToken);
        $user = User::findOrNew(
            find: [
                'email' => $userData->email
            ],
            update: [
                'email' => $userData->email,
                'password' => str()->random(40)->toString() // Random password as OAuth users don't need a password
            ]
        );

        $user->save();
        $authenticator->authenticate($user);

        return new Redirect('/auth/dashboard');
    }
}

If you want to switch provider you must only use different provider in route params like so :

#[Get('/auth/github')]
public function redirect(
    #[Tag('from_env_variables')] GithubProvider $provider
): Response

You can also configure the provider from user input or DB data or something else :

#[Get('/auth/google')]
public function redirect(
    GoogleProvider $provider,
    Request $request,
): Response
{
    $provider->configure(
        clientId: $request->get('client_id'),
        clientSecret: $request->get('client_secret'),
        redirectUri: $request->get('redirect_uri'),
    );
    
    return new Redirect(
        $provider->generateAuthorizationUrl()
    );
}

Finally, You also can use a GenericProvider to use a non-built-in provider or make your own :

#[Get('/auth/google')]
public function redirect(
    GenericProvider $provider,
): Response
{
    $provider->configure(
        clientId: env('GOOGLE_CLIENT_ID'),
        clientSecret: env('GOOGLE_CLIENT_SECRET'),
        redirectUri: env('GOOGLE_REDIRECT_URI'),
        defaultScopes: ['openid', 'profile', 'email'],
        authorizeEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
        accessTokenEndpoint: 'https://accounts.google.com/o/oauth2/v2/token',
        userDataEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
    );

    return new Redirect(
        $provider->generateAuthorizationUrl()
    );
}

@shaffe-fr
Copy link
Contributor

Hi @gturpin-dev,
If I'm correct, custom providers will not be able to use the tag from_env_variables and implement OauthProvider?
Maybe we should add an extra interface like BuiltinOauthProvider so we can restrict BuiltinOauthProvider Initializer to core providers?

Did you had something in mind?

@gturpin-dev
Copy link
Contributor Author

👋 @shaffe-fr I have to try non-built-in providers with the new implementation.
But I think for custom ones it still possible to have a userland initializer to do the same with or without a tag.
But maybe the #[Tag('from_env_variables')] doesn't allow it yet.

If possible, I'll try to avoid two interfaces to distinguish between a classic provider and a built-in one ( this is why I've done an enum to list them, but still don't 100% convinced by that btw )

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