Skip to content

feat: implement ORCiD OAuth authentication#50

Merged
rorybyrne merged 17 commits intomainfrom
028-feat-implement-authentication
Feb 6, 2026
Merged

feat: implement ORCiD OAuth authentication#50
rorybyrne merged 17 commits intomainfrom
028-feat-implement-authentication

Conversation

@rorybyrne
Copy link
Copy Markdown
Contributor

Summary

  • Implements OAuth 2.0 authentication using ORCiD as identity provider
  • Adds JWT-based session management with refresh token rotation
  • Includes full frontend integration with React context and SDK

Backend Changes

  • Auth routes: /auth/login, /auth/callback, /auth/refresh, /auth/logout
  • Domain models: User, Identity, RefreshToken with proper DDD structure
  • Services: AuthService (user management), TokenService (JWT generation)
  • ORCiD provider: Full OAuth 2.0 flow with sandbox support
  • Security: Signed state tokens (HMAC-SHA256) for CSRF protection
  • Database: New tables for users, identities, refresh_tokens with Alembic migration

Frontend Changes

  • AuthProvider: React context for auth state management
  • LoginButton: ORCiD-branded sign-in button
  • UserMenu: Dropdown with user info and logout
  • SDK: Token storage, auto-refresh, callback handling

Infrastructure

  • Simplified Justfile log commands
  • Fixed docker-compose.dev.yml for hot-reload development
  • Added curl to builder stage for healthchecks

Test plan

  • Unit tests for AuthService, TokenService, state signing
  • Manual test: OAuth flow with ORCiD sandbox
  • Contract tests for auth endpoints
  • Integration tests with real database

Related issues

Backend:
- Add auth routes (login, callback, refresh, logout) with OAuth 2.0 flow
- Add domain models for User, Identity, and RefreshToken
- Add AuthService and TokenService with JWT token generation
- Add ORCiD identity provider with sandbox support
- Add signed state tokens for CSRF protection (HMAC-SHA256)
- Add database tables and Alembic migration for auth entities
- Add unit tests for auth service, token service, and state signing

Frontend:
- Add AuthProvider context with SDK integration
- Add LoginButton and UserMenu components
- Add auth SDK (client, storage, types) for token management
- Add auth callback page for OAuth redirect handling

Infrastructure:
- Simplify Justfile log commands (just logs <service>)
- Fix docker-compose.dev.yml for hot-reload development
- Add curl to Dockerfile builder stage for healthchecks
- Expose server port 8000 in dev mode for OAuth redirects
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 5, 2026

Code Coverage

Package Line Rate Complexity Health
. 73% 0
application 0% 0
application.api 100% 0
application.api.rest 0% 0
application.api.v1 0% 0
application.api.v1.routes 0% 0
application.event 100% 0
cli 40% 0
cli.commands 18% 0
cli.util 53% 0
domain 100% 0
domain.auth 100% 0
domain.auth.command 98% 0
domain.auth.event 100% 0
domain.auth.model 89% 0
domain.auth.port 98% 0
domain.auth.query 100% 0
domain.auth.service 94% 0
domain.auth.util 100% 0
domain.auth.util.di 0% 0
domain.curation 100% 0
domain.curation.adapter 100% 0
domain.curation.command 100% 0
domain.curation.event 0% 0
domain.curation.handler 0% 0
domain.curation.model 100% 0
domain.curation.port 100% 0
domain.curation.query 100% 0
domain.curation.service 100% 0
domain.deposition 100% 0
domain.deposition.adapter 100% 0
domain.deposition.command 0% 0
domain.deposition.event 100% 0
domain.deposition.model 0% 0
domain.deposition.port 0% 0
domain.deposition.query 100% 0
domain.deposition.service 0% 0
domain.export 100% 0
domain.export.adapter 100% 0
domain.export.command 100% 0
domain.export.event 100% 0
domain.export.model 100% 0
domain.export.port 100% 0
domain.export.query 100% 0
domain.export.service 100% 0
domain.index 100% 0
domain.index.event 100% 0
domain.index.handler 76% 0
domain.index.model 84% 0
domain.index.service 100% 0
domain.record 100% 0
domain.record.adapter 100% 0
domain.record.command 100% 0
domain.record.event 100% 0
domain.record.handler 0% 0
domain.record.model 100% 0
domain.record.port 100% 0
domain.record.query 100% 0
domain.record.service 100% 0
domain.schema 100% 0
domain.schema.adapter 100% 0
domain.schema.command 100% 0
domain.schema.event 100% 0
domain.schema.model 100% 0
domain.schema.port 100% 0
domain.schema.query 100% 0
domain.schema.service 100% 0
domain.search 100% 0
domain.search.adapter 100% 0
domain.search.command 100% 0
domain.search.event 100% 0
domain.search.model 100% 0
domain.search.port 100% 0
domain.search.query 100% 0
domain.search.service 100% 0
domain.shared 83% 0
domain.shared.model 90% 0
domain.shared.port 100% 0
domain.source 100% 0
domain.source.event 100% 0
domain.source.handler 0% 0
domain.source.model 76% 0
domain.source.schedule 0% 0
domain.source.service 92% 0
domain.validation 100% 0
domain.validation.adapter 100% 0
domain.validation.command 0% 0
domain.validation.event 0% 0
domain.validation.handler 0% 0
domain.validation.model 0% 0
domain.validation.port 0% 0
domain.validation.query 100% 0
domain.validation.service 0% 0
infrastructure 100% 0
infrastructure.auth 0% 0
infrastructure.event 66% 0
infrastructure.index 0% 0
infrastructure.index.vector 76% 0
infrastructure.messaging 100% 0
infrastructure.oci 0% 0
infrastructure.persistence 0% 0
infrastructure.persistence.adapter 0% 0
infrastructure.source 0% 0
sdk 100% 0
sdk.index 100% 0
sdk.source 100% 0
util 100% 0
util.di 8% 0
Summary 44% (1689 / 3821) 0

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Feb 5, 2026

Greptile Overview

Greptile Summary

Implements OAuth 2.0 authentication with ORCiD as identity provider, including JWT-based sessions, refresh token rotation, and full frontend integration. Backend follows clean DDD architecture with proper separation of concerns.

Key Changes

  • Backend: OAuth routes, domain models (User/Identity/RefreshToken), AuthService, TokenService, ORCiD provider adapter
  • Security: HMAC-SHA256 signed state tokens for CSRF protection, SHA256 hashed refresh tokens with family-based theft detection
  • Frontend: React AuthProvider context, SDK client with auto-refresh, localStorage token management
  • Database: Alembic migration for auth tables with proper indexes and CASCADE constraints

Critical Issues Found

  • Frontend SDK breaks OAuth flow: Missing required provider query parameter in login URL causes 400 error
  • Parameter mismatch: Backend sends external_id but frontend expects orcid_id - callback parsing will fail
  • Config vulnerability: JWT secret allows empty string default, bypassing 32-character validation

Security Strengths

  • Proper refresh token rotation with family-based revocation on reuse detection
  • Signed OAuth state tokens with expiry (5 min) prevent CSRF and replay attacks
  • Constant-time HMAC comparison prevents timing attacks
  • Refresh tokens stored as SHA256 hashes, not plaintext

Architecture Quality

Backend implementation is solid with clean domain-driven design, proper error handling, and comprehensive test coverage. The token service correctly implements RFC 6749 OAuth 2.0 with additional security measures.

Confidence Score: 2/5

  • This PR cannot be safely merged - critical bugs in frontend SDK will cause OAuth flow to fail completely
  • Score reflects critical runtime bugs: missing provider parameter prevents login initialization, and orcid_id/external_id mismatch breaks callback parsing. While backend is well-implemented with proper security, the frontend integration is broken and requires fixes before deployment.
  • web/src/lib/sdk/auth.ts and web/src/lib/sdk/types.ts require immediate fixes for parameter naming, and server/osa/config.py needs JWT secret validation improvement

Important Files Changed

Filename Overview
web/src/lib/sdk/auth.ts Added OAuth client with token management - critical bugs: missing provider parameter in login URL, parameter name mismatch (orcid_id vs external_id) breaks callback parsing
web/src/lib/sdk/types.ts Added TypeScript types for auth SDK - field naming inconsistency (orcidId should be externalId)
server/osa/config.py Added auth config with JWT and ORCiD settings - JWT secret validation can be bypassed with empty string default
server/osa/application/api/v1/routes/auth.py Implemented OAuth routes with proper state validation and token issuance - well structured with good error handling
server/osa/domain/auth/service/auth.py Implemented auth service with user management and token rotation - solid implementation with proper refresh token family revocation on theft detection
server/osa/domain/auth/service/token.py Implemented JWT and OAuth state token services with HMAC-SHA256 signing - proper CSRF protection and constant-time comparison

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant Backend
    participant ORCiD

    User->>Frontend: Click "Sign in with ORCiD"
    Frontend->>Backend: GET /auth/login?provider=orcid&redirect_uri={url}
    Backend->>Backend: Create signed state token (HMAC-SHA256)
    Backend->>Backend: Generate authorization URL
    Backend-->>Frontend: 302 Redirect to ORCiD
    Frontend->>ORCiD: Redirect to authorization page
    User->>ORCiD: Authorize application
    ORCiD-->>Backend: GET /auth/callback?code={code}&state={state}
    Backend->>Backend: Verify signed state token
    Backend->>ORCiD: POST /oauth/token (exchange code)
    ORCiD-->>Backend: Return access_token + user info
    Backend->>Backend: Find or create User + Identity
    Backend->>Backend: Create JWT access token (HS256)
    Backend->>Backend: Create refresh token (hash & store)
    Backend-->>Frontend: 302 Redirect with tokens in URL hash
    Frontend->>Frontend: Parse tokens from hash
    Frontend->>Frontend: Store tokens in localStorage
    Frontend->>Frontend: Setup auto-refresh timer
    Frontend->>User: Show authenticated UI
Loading

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

8 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile

Comment thread server/osa/application/api/v1/routes/auth.py
Comment thread web/src/lib/sdk/auth.ts Outdated
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${stored.tokens.accessToken}`,
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.

The Authorization header is included in the logout request but the backend doesn't validate it - the endpoint only requires the refresh_token in the body. The header is unnecessary here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/lib/sdk/auth.ts
Line: 157:157

Comment:
The `Authorization` header is included in the logout request but the backend doesn't validate it - the endpoint only requires the `refresh_token` in the body. The header is unnecessary here.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +47 to +48
// Clear hash from URL
window.history.replaceState(null, '', window.location.pathname + window.location.search);
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.

The window.history.replaceState approach works, but setting window.location.hash = '' would be simpler and more direct for clearing the hash.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/components/auth/AuthProvider.tsx
Line: 47:48

Comment:
The `window.history.replaceState` approach works, but setting `window.location.hash = ''` would be simpler and more direct for clearing the hash.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment thread server/osa/config.py
Comment thread server/.env.example Outdated
# =============================================================================

# JWT signing secret (generate with: openssl rand -hex 32)
# IMPORTANT: Use a strong, unique secret in production!
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.

Consider recommending a minimum length (e.g., 256 bits / 32 bytes) for the JWT secret in the comment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/.env.example
Line: 36:36

Comment:
Consider recommending a minimum length (e.g., 256 bits / 32 bytes) for the JWT secret in the comment.

How can I resolve this? If you propose a fix, please make it concise.

- callback/page: use useSearchParams hook and Next.js Link component
- AuthProvider: simplify to standard useEffect pattern with eslint-disable
- auth.ts: remove unused LocalTokenStorage import
- pre-commit: add web-lint hook to catch frontend issues locally

The auth UI shows a brief loading state on page load because tokens
are stored in localStorage (not accessible during SSR). This is
tracked in issue #51 for a proper cookie-based solution.
- Make JWT secret a required field with no default
- Add model_validator ensuring minimum 32 character length
- Remove default values from JwtConfig.secret, AuthConfig.jwt, and Config.auth
- Add type: ignore[call-arg] at Config() call sites where Pydantic Settings
  populates from environment variables at runtime
- Update tests to use secrets with 32+ characters
- Set test secret in conftest.py before module imports
- Sanitize error messages in OAuth callback (don't expose exception details)
@rorybyrne
Copy link
Copy Markdown
Contributor Author

Code review

Found 6 issues:

  1. Business logic in router - OAuth state signing/verification functions contain HMAC-SHA256 cryptographic logic directly in the routes file (CLAUDE.md says "No business logic in routers — they translate HTTP ↔ DTOs ↔ handlers")

def _create_signed_state(secret: str, redirect_uri: str) -> str:
"""Create a signed, self-verifying OAuth state token.
The state contains: nonce, redirect_uri, expiry timestamp.
Signed with HMAC-SHA256 using the JWT secret.
"""
payload = {
"nonce": secrets.token_urlsafe(16),
"redirect_uri": redirect_uri,
"exp": int(time.time()) + _STATE_EXPIRY_SECONDS,
}
payload_bytes = json.dumps(payload, separators=(",", ":")).encode()
payload_b64 = urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
signature = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).digest()
signature_b64 = urlsafe_b64encode(signature).rstrip(b"=").decode()
return f"{payload_b64}.{signature_b64}"
def _verify_signed_state(secret: str, state: str) -> str | None:
"""Verify a signed state token and return the redirect_uri if valid.
Returns None if the state is invalid or expired.
"""
try:
parts = state.split(".")
if len(parts) != 2:
return None
payload_b64, signature_b64 = parts
# Restore base64 padding
payload_bytes = urlsafe_b64decode(payload_b64 + "==")
signature = urlsafe_b64decode(signature_b64 + "==")
# Verify signature
expected_sig = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected_sig):
logger.warning("OAuth state signature verification failed")
return None
# Parse and check expiry
payload = json.loads(payload_bytes)
if payload.get("exp", 0) < time.time():
logger.warning("OAuth state expired")
return None
return payload.get("redirect_uri")
except Exception as e:
logger.warning("OAuth state verification error: %s", e)
return None

  1. Race condition in token refresh - refresh_tokens() has a TOCTOU vulnerability: two concurrent requests with the same valid token can both pass validation before either revokes, issuing two valid refresh tokens and bypassing theft detection. Needs SELECT FOR UPDATE or atomic operation.

"""
from osa.domain.shared.error import InvalidStateError
token_hash = self._token_service.hash_token(refresh_token_raw)
stored_token = await self._refresh_token_repo.get_by_token_hash(token_hash)
if stored_token is None:
raise InvalidStateError("Invalid refresh token", code="invalid_refresh_token")
if stored_token.is_revoked:
# Potential theft detected - revoke entire family
await self._refresh_token_repo.revoke_family(stored_token.family_id)
logger.warning(
"Refresh token reuse detected, family revoked: family_id=%s",
stored_token.family_id,
)
raise InvalidStateError(
"Token family revoked - please login again",
code="token_family_revoked",
)
if stored_token.is_expired:
raise InvalidStateError("Refresh token expired", code="refresh_token_expired")
# Revoke old token
stored_token.revoke()
await self._refresh_token_repo.save(stored_token)

  1. FastAPI-specific code in domain layer - deps.py contains FastAPI imports (HTTPException, HTTPBearer, Depends) within domain/auth/, violating protocol-agnostic domain requirement (CLAUDE.md says "NO api/ folder - domains are protocol-agnostic")

"""FastAPI dependencies for authentication."""
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from osa.config import Config
from osa.domain.auth.model.value import UserId
# HTTP Bearer token security scheme
security = HTTPBearer(auto_error=False)
class CurrentUser:
"""Authenticated user from JWT token."""
def __init__(self, user_id: UserId, orcid_id: str):
self.user_id = user_id
self.orcid_id = orcid_id
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
config: Config,
) -> CurrentUser:
"""Extract and validate current user from JWT token.
Usage in routes:
@router.get("/protected")
async def protected_endpoint(
current_user: Annotated[CurrentUser, Depends(get_current_user)],
):
...
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "missing_token", "message": "Authorization header required"},
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
credentials.credentials,
config.auth.jwt.secret,
algorithms=[config.auth.jwt.algorithm],
audience="authenticated",
)
user_id = UserId.model_validate(payload["sub"])
orcid_id = payload["orcid_id"]
return CurrentUser(user_id=user_id, orcid_id=orcid_id)
except jwt.ExpiredSignatureError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "token_expired", "message": "Token has expired"},
headers={"WWW-Authenticate": "Bearer"},
) from e
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "invalid_token", "message": "Invalid token"},
headers={"WWW-Authenticate": "Bearer"},
) from e

  1. Routes bypass CommandHandlers - Auth routes directly call AuthService methods instead of using the defined CommandHandler classes (InitiateLoginHandler, CompleteOAuthHandler exist but are unused) (CLAUDE.md shows pattern "Router → CommandHandler → Service → Repository")

# Complete OAuth flow
user, identity, access_token, refresh_token = await auth_service.complete_oauth(
provider=identity_provider,
code=code,
redirect_uri=callback_url,
)

  1. Missing event emission - UserAuthenticated and UserLoggedOut events are defined but never emitted via Outbox in complete_oauth() or logout() (CLAUDE.md says "Use Outbox to emit domain events")

async def complete_oauth(
self,
provider: IdentityProvider,
code: str,
redirect_uri: str,
) -> tuple[User, Identity, str, str]:
"""Complete OAuth flow and issue tokens.
Args:
provider: The identity provider
code: Authorization code from callback
redirect_uri: Must match the one used in authorization
Returns:
Tuple of (user, identity, access_token, refresh_token)
"""
# Exchange code for identity info
identity_info = await provider.exchange_code(code, redirect_uri)
# Find or create user and identity
user, identity = await self._find_or_create_user(identity_info)
# Create tokens
access_token, refresh_token = await self._create_tokens(user, identity)
logger.info(
"User authenticated: user_id=%s, provider=%s, external_id=%s",
user.id,
identity.provider,
identity.external_id,
)
return user, identity, access_token, refresh_token

  1. Missing contract tests - PR adds 5 auth endpoints but only unit tests exist, no contract tests using FastAPI test client (CLAUDE.md requires "Contract tests for API routes")

https://github.com/opensciencearchive/server/blob/ac28b85d9a32a43d44cfcebb5f874ae059e385a2/server/tests/unit/domain/auth/

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- Add Suspense boundary around AuthCallbackContent component to fix
  Next.js static generation error with useSearchParams()
- Add web-build pre-commit hook to catch build failures locally
- Add CurrentUser dataclass to auth domain model (value.py)
- Add Dishka provider for CurrentUser that extracts JWT from Authorization header
- Simplify /me endpoint to use FromDishka[CurrentUser]
- Delete unused deps.py (FastAPI Depends implementation)
- Remove unnecessary Authorization header from logout request in frontend
Move create_oauth_state and verify_oauth_state methods from routes to
TokenService where other token-related cryptographic operations live.
Routers should only handle HTTP translation, not business logic.
- Update InitiateLoginHandler to use TokenService for signed state
- Update CompleteOAuthHandler to emit UserAuthenticated event
- Add RefreshTokensHandler and LogoutHandler with UserLoggedOut event
- Register all handlers in DI provider
- Update routes to use handlers instead of calling services directly
- Add unit tests for all command handlers

This establishes the Route → Handler → Service → Repository pattern
for auth operations, enabling future cross-cutting concerns.
Remove hardcoded ORCID references to support multiple identity providers:

- Add ProviderIdentity value object encapsulating provider + external_id
- Add ProviderRegistry port and InMemoryProviderRegistry implementation
- Update CurrentUser to use ProviderIdentity instead of orcid_id
- Update TokenService JWT claims to use provider/external_id
- Update OAuth state to include provider for callback routing
- Update command handlers to look up providers from registry
- Update API routes to validate against configured providers
- Rename UserAuthenticated.orcid_id to external_id
- Rename AuthService.get_orcid_identity() to get_primary_identity()
@rorybyrne
Copy link
Copy Markdown
Contributor Author

@greptile

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

6 files reviewed, 9 comments

Edit Code Review Agent Settings | Greptile

Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread web/src/lib/sdk/types.ts Outdated
Comment thread web/src/lib/sdk/types.ts Outdated
Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread web/src/lib/sdk/auth.ts Outdated
Comment thread server/osa/config.py
rorybyrne and others added 9 commits February 6, 2026 01:53
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Update user property naming from orcidId to externalId in UserMenu
component, auth client, API client, and type definitions to use
more generic terminology for external identity providers
Add row-level locking (SELECT FOR UPDATE) when fetching refresh tokens
during the refresh flow. This prevents concurrent requests from both
passing the is_revoked check before either revokes the token, which
would defeat the theft detection mechanism.
@rorybyrne rorybyrne merged commit f650d7d into main Feb 6, 2026
6 checks passed
@rorybyrne rorybyrne deleted the 028-feat-implement-authentication branch February 6, 2026 12:35
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.

feat: implement authentication with ORCiD as identity provider

1 participant