Skip to content

Commit

Permalink
update orcid auth flow (#713)
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentauger authored Aug 8, 2024
1 parent 00e2ad0 commit 97c7c88
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 98 deletions.
76 changes: 51 additions & 25 deletions app/Http/Controllers/Orcid/FullFlowController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
namespace App\Http\Controllers\Orcid;

use App\Enums\ORCID\ORCIDAuthScope;
use App\Http\Resources\AuthorResource;
use App\Models\User;
use App\Traits\LocaleTrait;
use Auth;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Spatie\Activitylog\Facades\CauserResolver;

/**
* Implements the 3-legged OAuth flow for ORCID. More information can be found at
Expand All @@ -19,28 +23,57 @@ class FullFlowController
{
use LocaleTrait;

public function __invoke(Request $request): JsonResource
public function callback(Request $request): RedirectResponse
{
Log::debug("Callback from ORCID");
Log::debug($request->all());

$frontendUrl = config('app.frontend_url');

$validated = $request->validate([
'code' => 'required|string|alpha_num',
'key' => 'required|string|alpha_num',
]);

if (! Cache::has($validated['key'])) {
Log::error('Invalid key for ORCID callback');
$url = $frontendUrl . '#/auth/orcid-callback?status=invalid-key';
return redirect($url);
}

Log::info('Key found for ORCID callback');

$user = User::find(Cache::pull($validated['key']));

if (! $user) {
Log::error('Invalid user for ORCID callback');
$url = $frontendUrl . '#/auth/orcid-callback?status=invalid-user';
return redirect($url);
}

Log::info('User found for ORCID callback');
Log::debug($user);

$baseUrl = $this->getBaseUrl();

$payload = [
'client_id' => config('osp.orcid.client_id'),
'client_secret' => config('osp.orcid.client_secret'),
'grant_type' => 'authorization_code',
'code' => $validated['code'],
'redirect_uri' => config('osp.orcid.redirect_uri'),
'redirect_uri' => config('osp.orcid.redirect_uri').'?key='.$validated['key'],
];

Log::info('Requesting access token from ORCID');
$response = Http::asForm()->accept('application/json')->post("https://$baseUrl/oauth/token", $payload);

if ($response->failed()) {
throw ValidationException::withMessages(['code' => 'Invalid code']);
Log::error('Failed to get access token from ORCID');
Log::debug($response->body());
return redirect($frontendUrl . '#/auth/orcid-callback?status=failed');
}

Log::info('Access token received from ORCID');
$accessToken = $response->json('access_token');
$expiresIn = $response->json('expires_in');
$refreshToken = $response->json('refresh_token');
Expand All @@ -50,19 +83,19 @@ public function __invoke(Request $request): JsonResource
// we shoudl be able to use the access token to get the user's ORCID iD
//$orcid = $this->getOrcidId($accessToken);

// get user's author record
$user = auth()->user();
CauserResolver::setCauser($user);

$author = $user->author;

$author->orcid = 'https://'.$baseUrl.'/'.$orcid;
$author->orcid = 'https://' . $baseUrl . '/' . $orcid;
$author->orcid_access_token = $accessToken;
$author->orcid_token_scope = $scope;
$author->orcid_refresh_token = $refreshToken;
$author->orcid_verified = true;
$author->orcid_expires_at = now()->addSeconds($expiresIn);
$author->save();

return AuthorResource::make($author);
return redirect($frontendUrl . '#/auth/orcid-callback?status=success');
}

/**
Expand All @@ -82,6 +115,13 @@ public function redirect(Request $request): RedirectResponse
throw ValidationException::withMessages(['orcid' => __('Server side ORCID configuration is missing - please contact the administrator.')]);
}

// cache the user id with a random string for 15 mintues
$key = Str::random(16);
Cache::add($key, Auth::id(), now()->addMinutes(15));

// add key to redirect URI
$redirectURI .= '?key=' . $key;

// encode URI - if it's not encoded, ORCID will returns to base URL
$encoded = urlencode($redirectURI);
$scope = ORCIDAuthScope::completeAccess();
Expand All @@ -91,26 +131,12 @@ public function redirect(Request $request): RedirectResponse
// build the link
$link = "https://$base_url/oauth/authorize?client_id=$clientID&response_type=code&scope=$scope&redirect_uri=$encoded&lang=$locale";

Log::info('Redirecting to ORCID for authorization');
Log::debug($link);
// redirect to ORCID
return redirect($link);
}

/**
* Redirects to the frontend with the ORCID code. This is required as the OAuth server
* will not accept an URL with a hash in the 3-legged flow.
*/
public function redirectToFrontend(Request $request): RedirectResponse
{
$validated = $request->validate([
'code' => 'required|string|alpha_num',
]);

$frontendUrl = config('app.frontend_url');
$url = $frontendUrl.'#/auth/orcid-callback?code='.$validated['code'];

return redirect($url);
}

protected function getBaseUrl(): string
{
return config('osp.orcid.use_sandbox') ? 'sandbox.orcid.org' : 'orcid.org';
Expand Down
5 changes: 4 additions & 1 deletion resources/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,10 @@
"error-verifying": "Error verifying with ORCID.org",
"verified-with": "Successfully verified with ORCID.org",
"verify-header-text": "Connect your ORCID iD (or register for an iD)",
"verifying": "Verifying with ORCID.org"
"verifying": "Verifying with ORCID.org",
"invalid-key": "Invalid key - likely due to timeout - please try again.",
"invalid-user": "User not found - internal error - please contact us.",
"failed": "A probelm occured in authorizing your account with ORCID.org"
},
"organization-select": {
"add-a-new-organization": "Add a new organization",
Expand Down
5 changes: 4 additions & 1 deletion resources/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,10 @@
"error-verifying": "Erreur lors de la vérification avec ORCID.org",
"verified-with": "Vérifié avec succès avec ORCID.org",
"verify-header-text": "Connectez votre ORCID iD (ou inscrivez-vous pour un iD)",
"verifying": "Vérification avec ORCID.org"
"verifying": "Vérification avec ORCID.org",
"invalid-key": "Clé non valide - probablement en raison d'un délai d'attente - veuillez réessayer.",
"invalid-user": "Utilisateur introuvable – erreur interne – veuillez nous contacter.",
"failed": "Un problème est survenu lors de l'autorisation de votre compte avec ORCID.org"
},
"organization-select": {
"add-a-new-organization": "Ajouter une nouvelle organisation",
Expand Down
138 changes: 75 additions & 63 deletions resources/src/models/auth/components/OrcidCallbackCard.vue
Original file line number Diff line number Diff line change
@@ -1,72 +1,84 @@
<template>
<q-card>
<q-card-section class="flex flex-center column">
<template v-if="loading">
<h5>{{ $t('orcid.verifying') }}</h5>
<q-spinner-dots size="50px" color="primary" />
</template>
<template v-else-if="valid">
<q-icon
name="mdi-check-decagram-outline"
size="50px"
color="primary"
class="q-mb-md"
/>
<h5 class="q-my-none text-center">
{{ $t('orcid.verified-with') }}
</h5>

<h6 class="q-mt-md">{{ author?.orcid }}</h6>
<q-btn
label="Continue"
color="primary"
@click="
$router.push({
name: 'settings.author',
})
"
/>
</template>
<template v-else>
<q-icon name="error" size="50px" color="red" />
<h5>{{ $t('orcid.error-verifying') }}</h5>
</template>
</q-card-section>
</q-card>
</template>

<script setup lang="ts">
import { Author, AuthorService } from '@/models/Author/Author';
import type { Author } from '@/models/Author/Author'
import { AuthorService } from '@/models/Author/Author'
const router = useRouter()
const authStore = useAuthStore()
const { t } = useI18n()
const router = useRouter();
const authStore = useAuthStore();
type statuses = 'success' | 'invalid-key' | 'invalid-user' | 'failed' | undefined
const status = ref((router.currentRoute.value.query?.status as statuses) || undefined)
const code = ref((router.currentRoute.value.query?.code as string) || '');
const loading = ref(true)
const valid = computed(() => status.value === 'success')
const loading = ref(true);
const valid = ref(false);
const author = ref<Author | null>(null);
const errorMessage = computed(() => {
switch (status.value) {
case 'invalid-key':
return t('orcid.invalid-key')
case 'invalid-user':
return t('orcid.invalid-user')
case 'failed':
case undefined:
return t('orcid.failed')
default:
return ''
}
})
async function verifyWithBackend() {
AuthorService.verifyOrcid(code.value)
.then((response) => {
console.log(response);
valid.value = true;
author.value = response.data;
authStore.refreshUser(true);
})
.catch((error) => {
console.log(error);
})
.finally(() => {
loading.value = false;
});
}
const author = ref<Author | null>(null)
onMounted(() => {
console.log('mounted');
verifyWithBackend();
});
onMounted(async () => {
if (status.value === 'success') {
if (!authStore.user?.authorId) {
status.value = undefined
return
}
const { data } = await AuthorService.find(authStore.user.authorId)
author.value = data
}
loading.value = false
})
</script>

<template>
<q-card bordered flat class="bg-teal-1">
<q-card-section class="flex flex-center column">
<template v-if="loading">
<h5>{{ $t('orcid.verifying') }}</h5>
<q-spinner-dots size="50px" color="primary" />
</template>
<template v-else-if="valid">
<q-icon
name="mdi-check-decagram-outline"
size="50px"
color="primary"
class="q-mb-md"
/>
<h5 class="q-my-none text-center">
{{ $t('orcid.verified-with') }}
</h5>

<h6 class="q-mt-md">
{{ author?.orcid }}
</h6>
<q-btn
label="Continue"
color="primary"
@click="
router.push({
name: 'settings.author',
})
"
/>
</template>
<template v-else>
<q-icon name="error" size="50px" color="red" />
<h5>{{ t('orcid.error-verifying') }}</h5>
<p>{{ errorMessage }}</p>
</template>
</q-card-section>
</q-card>
</template>

<style scoped></style>
3 changes: 1 addition & 2 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
*/

Route::get('/status', CheckStatusPageController::class);
Route::get('/orcid/redirect', [FullFlowController::class, 'redirectToFrontend']);
Route::get('/orcid/callback', [FullFlowController::class, 'callback']);

// Need to ben authenticated to access these routes
Route::middleware(['auth:sanctum'])->group(function () {
Expand All @@ -56,7 +56,6 @@
// Route::post('/user/orcid/verify', ImplicitFlowController::class);
// Route::get('/user/orcid/verify', [ImplicitFlowController::class, 'redirect']);
// Routes for 3-legged OAuth flow
Route::post('/user/orcid/verify', FullFlowController::class);
Route::get('/user/orcid/verify', [FullFlowController::class, 'redirect']);

Route::controller(AuthorController::class)->group(function () {
Expand Down
7 changes: 1 addition & 6 deletions tests/Feature/OrcidIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@
$response = $this->actingAs($user)->get('api/user/orcid/verify?locale=fr');

expect($response->status())->toBe(302);
expect($response->headers->get('Location'))->toBe("https://orcid.org/oauth/authorize?client_id=$client_id&response_type=code&scope=$scopes&redirect_uri=$encoded&lang=fr");
});
expect($response->headers->get('Location'))->toStartWith("https://orcid.org/oauth/authorize?client_id=$client_id&response_type=code&scope=$scopes&redirect_uri=$encoded")->toEndWith('&lang=fr');

test('check front end redirect rule', function () {
$response = $this->get('/api/orcid/redirect?code=1234');
expect($response->status())->toBe(302);
expect($response->headers->get('Location'))->toBe(config('app.frontend_url').'#/auth/orcid-callback?code=1234');
});

0 comments on commit 97c7c88

Please sign in to comment.